refactor(core): replace Rc-based Context with a synchronous, Send-able Core (#123)
Make the conversations core Send so the threaded client can own it behind an
Arc<Mutex<Core>>: a background worker polls the transport and handles inbound
payloads while the application thread issues outbound calls (send, create
conversation). Sharing the core across those two threads means moving it into
the spawned worker, which is only legal if it is Send. Access stays serialized
by the client's Mutex (one thread at a time), so the core needs Send but not
Sync and carries no lock of its own. See
docs/adr/0001-client-event-system.md for the background-poller design.
The Rc<RefCell> service-sharing is what made the core !Send. Context is de-Rc'd
and renamed to Core, owning its services outright and driving the inbox and
conversation primitives with plain &mut self.
- Services (identity, delivery, store, registry, MLS context, causal history)
are bundled into a ServiceContext<S> behind an ExternalServices trait, with
S = (DS, RS, CS). Constructors live on the (DS, RS, CS) form because S cannot
be inferred backwards through S::DS.
- Inbox, InboxV2, PrivateV1Convo, and GroupV1Convo become non-generic and
receive the ServiceContext bundle as a &mut/& parameter; no Rc or
RefCell-as-shared-state remains, so Core is Send whenever its injected
services are.
- Dispatch branches on ConversationKind in one place: Core rebuilds the target
as a Convo<S>/GroupConvo<S> trait object bound to the service bundle, so
conversations never escape the orchestrator.
- CausalHistoryStore drops its Rc, keeping a plain RefCell.
2026-06-08 21:55:33 +02:00
|
|
|
use crate::causal_history::{CausalHistoryStore, MissingMessage};
|
2026-06-19 08:43:55 -07:00
|
|
|
use crate::conversation::{
|
2026-06-19 12:01:17 -07:00
|
|
|
ConversationIdRef, DirectV1Convo, GroupV1Convo, GroupV2Convo, Identified, PrivateV1Convo,
|
2026-06-19 08:43:55 -07:00
|
|
|
};
|
refactor(core): replace Rc-based Context with a synchronous, Send-able Core (#123)
Make the conversations core Send so the threaded client can own it behind an
Arc<Mutex<Core>>: a background worker polls the transport and handles inbound
payloads while the application thread issues outbound calls (send, create
conversation). Sharing the core across those two threads means moving it into
the spawned worker, which is only legal if it is Send. Access stays serialized
by the client's Mutex (one thread at a time), so the core needs Send but not
Sync and carries no lock of its own. See
docs/adr/0001-client-event-system.md for the background-poller design.
The Rc<RefCell> service-sharing is what made the core !Send. Context is de-Rc'd
and renamed to Core, owning its services outright and driving the inbox and
conversation primitives with plain &mut self.
- Services (identity, delivery, store, registry, MLS context, causal history)
are bundled into a ServiceContext<S> behind an ExternalServices trait, with
S = (DS, RS, CS). Constructors live on the (DS, RS, CS) form because S cannot
be inferred backwards through S::DS.
- Inbox, InboxV2, PrivateV1Convo, and GroupV1Convo become non-generic and
receive the ServiceContext bundle as a &mut/& parameter; no Rc or
RefCell-as-shared-state remains, so Core is Send whenever its injected
services are.
- Dispatch branches on ConversationKind in one place: Core rebuilds the target
as a Convo<S>/GroupConvo<S> trait object bound to the service bundle, so
conversations never escape the orchestrator.
- CausalHistoryStore drops its Rc, keeping a plain RefCell.
2026-06-08 21:55:33 +02:00
|
|
|
use crate::service_context::{ExternalServices, ServiceContext};
|
2026-07-10 23:26:05 -07:00
|
|
|
use crate::types::ConvoMetadata;
|
2026-07-11 02:08:51 +03:00
|
|
|
use crate::{
|
|
|
|
|
DeliveryService, GroupV2Clock, GroupV2Config, IdentityProvider, RegistrationService,
|
|
|
|
|
WakeupService,
|
|
|
|
|
};
|
refactor(core): replace Rc-based Context with a synchronous, Send-able Core (#123)
Make the conversations core Send so the threaded client can own it behind an
Arc<Mutex<Core>>: a background worker polls the transport and handles inbound
payloads while the application thread issues outbound calls (send, create
conversation). Sharing the core across those two threads means moving it into
the spawned worker, which is only legal if it is Send. Access stays serialized
by the client's Mutex (one thread at a time), so the core needs Send but not
Sync and carries no lock of its own. See
docs/adr/0001-client-event-system.md for the background-poller design.
The Rc<RefCell> service-sharing is what made the core !Send. Context is de-Rc'd
and renamed to Core, owning its services outright and driving the inbox and
conversation primitives with plain &mut self.
- Services (identity, delivery, store, registry, MLS context, causal history)
are bundled into a ServiceContext<S> behind an ExternalServices trait, with
S = (DS, RS, CS). Constructors live on the (DS, RS, CS) form because S cannot
be inferred backwards through S::DS.
- Inbox, InboxV2, PrivateV1Convo, and GroupV1Convo become non-generic and
receive the ServiceContext bundle as a &mut/& parameter; no Rc or
RefCell-as-shared-state remains, so Core is Send whenever its injected
services are.
- Dispatch branches on ConversationKind in one place: Core rebuilds the target
as a Convo<S>/GroupConvo<S> trait object bound to the service bundle, so
conversations never escape the orchestrator.
- CausalHistoryStore drops its Rc, keeping a plain RefCell.
2026-06-08 21:55:33 +02:00
|
|
|
use crate::{
|
2026-06-15 13:15:18 -07:00
|
|
|
conversation::{Convo, GroupConvo},
|
refactor(core): replace Rc-based Context with a synchronous, Send-able Core (#123)
Make the conversations core Send so the threaded client can own it behind an
Arc<Mutex<Core>>: a background worker polls the transport and handles inbound
payloads while the application thread issues outbound calls (send, create
conversation). Sharing the core across those two threads means moving it into
the spawned worker, which is only legal if it is Send. Access stays serialized
by the client's Mutex (one thread at a time), so the core needs Send but not
Sync and carries no lock of its own. See
docs/adr/0001-client-event-system.md for the background-poller design.
The Rc<RefCell> service-sharing is what made the core !Send. Context is de-Rc'd
and renamed to Core, owning its services outright and driving the inbox and
conversation primitives with plain &mut self.
- Services (identity, delivery, store, registry, MLS context, causal history)
are bundled into a ServiceContext<S> behind an ExternalServices trait, with
S = (DS, RS, CS). Constructors live on the (DS, RS, CS) form because S cannot
be inferred backwards through S::DS.
- Inbox, InboxV2, PrivateV1Convo, and GroupV1Convo become non-generic and
receive the ServiceContext bundle as a &mut/& parameter; no Rc or
RefCell-as-shared-state remains, so Core is Send whenever its injected
services are.
- Dispatch branches on ConversationKind in one place: Core rebuilds the target
as a Convo<S>/GroupConvo<S> trait object bound to the service bundle, so
conversations never escape the orchestrator.
- CausalHistoryStore drops its Rc, keeping a plain RefCell.
2026-06-08 21:55:33 +02:00
|
|
|
errors::ChatError,
|
|
|
|
|
inbox::Inbox,
|
|
|
|
|
inbox_v2::{InboxV2, MlsEphemeralPqProvider, MlsIdentityProvider},
|
|
|
|
|
outcomes::{ConvoOutcome, InboxOutcome, PayloadOutcome},
|
|
|
|
|
proto::{EncryptedPayload, EnvelopeV1, Message},
|
|
|
|
|
};
|
|
|
|
|
use crypto::{Identity, PublicKey};
|
2026-06-15 13:15:18 -07:00
|
|
|
use openmls::group::GroupId;
|
2026-07-03 23:18:10 +02:00
|
|
|
use shared_traits::{IdentId, IdentIdRef};
|
2026-06-15 13:15:18 -07:00
|
|
|
use std::collections::HashMap;
|
2026-06-19 12:01:17 -07:00
|
|
|
use std::fmt::Debug;
|
refactor(core): replace Rc-based Context with a synchronous, Send-able Core (#123)
Make the conversations core Send so the threaded client can own it behind an
Arc<Mutex<Core>>: a background worker polls the transport and handles inbound
payloads while the application thread issues outbound calls (send, create
conversation). Sharing the core across those two threads means moving it into
the spawned worker, which is only legal if it is Send. Access stays serialized
by the client's Mutex (one thread at a time), so the core needs Send but not
Sync and carries no lock of its own. See
docs/adr/0001-client-event-system.md for the background-poller design.
The Rc<RefCell> service-sharing is what made the core !Send. Context is de-Rc'd
and renamed to Core, owning its services outright and driving the inbox and
conversation primitives with plain &mut self.
- Services (identity, delivery, store, registry, MLS context, causal history)
are bundled into a ServiceContext<S> behind an ExternalServices trait, with
S = (DS, RS, CS). Constructors live on the (DS, RS, CS) form because S cannot
be inferred backwards through S::DS.
- Inbox, InboxV2, PrivateV1Convo, and GroupV1Convo become non-generic and
receive the ServiceContext bundle as a &mut/& parameter; no Rc or
RefCell-as-shared-state remains, so Core is Send whenever its injected
services are.
- Dispatch branches on ConversationKind in one place: Core rebuilds the target
as a Convo<S>/GroupConvo<S> trait object bound to the service bundle, so
conversations never escape the orchestrator.
- CausalHistoryStore drops its Rc, keeping a plain RefCell.
2026-06-08 21:55:33 +02:00
|
|
|
use storage::{ChatStore, ConversationKind, ConversationStore};
|
2026-06-15 13:15:18 -07:00
|
|
|
use tracing::{info, instrument};
|
refactor(core): replace Rc-based Context with a synchronous, Send-able Core (#123)
Make the conversations core Send so the threaded client can own it behind an
Arc<Mutex<Core>>: a background worker polls the transport and handles inbound
payloads while the application thread issues outbound calls (send, create
conversation). Sharing the core across those two threads means moving it into
the spawned worker, which is only legal if it is Send. Access stays serialized
by the client's Mutex (one thread at a time), so the core needs Send but not
Sync and carries no lock of its own. See
docs/adr/0001-client-event-system.md for the background-poller design.
The Rc<RefCell> service-sharing is what made the core !Send. Context is de-Rc'd
and renamed to Core, owning its services outright and driving the inbox and
conversation primitives with plain &mut self.
- Services (identity, delivery, store, registry, MLS context, causal history)
are bundled into a ServiceContext<S> behind an ExternalServices trait, with
S = (DS, RS, CS). Constructors live on the (DS, RS, CS) form because S cannot
be inferred backwards through S::DS.
- Inbox, InboxV2, PrivateV1Convo, and GroupV1Convo become non-generic and
receive the ServiceContext bundle as a &mut/& parameter; no Rc or
RefCell-as-shared-state remains, so Core is Send whenever its injected
services are.
- Dispatch branches on ConversationKind in one place: Core rebuilds the target
as a Convo<S>/GroupConvo<S> trait object bound to the service bundle, so
conversations never escape the orchestrator.
- CausalHistoryStore drops its Rc, keeping a plain RefCell.
2026-06-08 21:55:33 +02:00
|
|
|
|
|
|
|
|
pub use crate::conversation::ConversationId;
|
|
|
|
|
pub use crate::inbox::Introduction;
|
|
|
|
|
|
|
|
|
|
// This is the main entry point to the conversations api.
|
|
|
|
|
// `Core` manages lifetimes of objects to process and generate payloads.
|
|
|
|
|
//
|
|
|
|
|
// Fully synchronous and single-threaded: it owns its services outright (no
|
|
|
|
|
// interior mutability, no shared ownership) and drives the inbox/conversation
|
|
|
|
|
// primitives with plain `&mut self`.
|
|
|
|
|
pub struct Core<S: ExternalServices> {
|
|
|
|
|
services: ServiceContext<S>,
|
|
|
|
|
inbox: Inbox,
|
|
|
|
|
pq_inbox: InboxV2,
|
2026-06-15 13:15:18 -07:00
|
|
|
// Cache of loaded conversations
|
|
|
|
|
cached_convos: HashMap<String, ConvoTypeOwned<S>>,
|
refactor(core): replace Rc-based Context with a synchronous, Send-able Core (#123)
Make the conversations core Send so the threaded client can own it behind an
Arc<Mutex<Core>>: a background worker polls the transport and handles inbound
payloads while the application thread issues outbound calls (send, create
conversation). Sharing the core across those two threads means moving it into
the spawned worker, which is only legal if it is Send. Access stays serialized
by the client's Mutex (one thread at a time), so the core needs Send but not
Sync and carries no lock of its own. See
docs/adr/0001-client-event-system.md for the background-poller design.
The Rc<RefCell> service-sharing is what made the core !Send. Context is de-Rc'd
and renamed to Core, owning its services outright and driving the inbox and
conversation primitives with plain &mut self.
- Services (identity, delivery, store, registry, MLS context, causal history)
are bundled into a ServiceContext<S> behind an ExternalServices trait, with
S = (DS, RS, CS). Constructors live on the (DS, RS, CS) form because S cannot
be inferred backwards through S::DS.
- Inbox, InboxV2, PrivateV1Convo, and GroupV1Convo become non-generic and
receive the ServiceContext bundle as a &mut/& parameter; no Rc or
RefCell-as-shared-state remains, so Core is Send whenever its injected
services are.
- Dispatch branches on ConversationKind in one place: Core rebuilds the target
as a Convo<S>/GroupConvo<S> trait object bound to the service bundle, so
conversations never escape the orchestrator.
- CausalHistoryStore drops its Rc, keeping a plain RefCell.
2026-06-08 21:55:33 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Constructors live on the `(DS, RS, CS)` form: `S` can't be inferred backwards
|
|
|
|
|
// through `S::DS`, so the bundle is built from the three args here.
|
2026-06-15 13:15:18 -07:00
|
|
|
impl<IP, DS, RS, WS, CS> Core<(IP, DS, RS, WS, CS)>
|
refactor(core): replace Rc-based Context with a synchronous, Send-able Core (#123)
Make the conversations core Send so the threaded client can own it behind an
Arc<Mutex<Core>>: a background worker polls the transport and handles inbound
payloads while the application thread issues outbound calls (send, create
conversation). Sharing the core across those two threads means moving it into
the spawned worker, which is only legal if it is Send. Access stays serialized
by the client's Mutex (one thread at a time), so the core needs Send but not
Sync and carries no lock of its own. See
docs/adr/0001-client-event-system.md for the background-poller design.
The Rc<RefCell> service-sharing is what made the core !Send. Context is de-Rc'd
and renamed to Core, owning its services outright and driving the inbox and
conversation primitives with plain &mut self.
- Services (identity, delivery, store, registry, MLS context, causal history)
are bundled into a ServiceContext<S> behind an ExternalServices trait, with
S = (DS, RS, CS). Constructors live on the (DS, RS, CS) form because S cannot
be inferred backwards through S::DS.
- Inbox, InboxV2, PrivateV1Convo, and GroupV1Convo become non-generic and
receive the ServiceContext bundle as a &mut/& parameter; no Rc or
RefCell-as-shared-state remains, so Core is Send whenever its injected
services are.
- Dispatch branches on ConversationKind in one place: Core rebuilds the target
as a Convo<S>/GroupConvo<S> trait object bound to the service bundle, so
conversations never escape the orchestrator.
- CausalHistoryStore drops its Rc, keeping a plain RefCell.
2026-06-08 21:55:33 +02:00
|
|
|
where
|
2026-06-10 06:59:04 -07:00
|
|
|
IP: IdentityProvider + 'static,
|
refactor(core): replace Rc-based Context with a synchronous, Send-able Core (#123)
Make the conversations core Send so the threaded client can own it behind an
Arc<Mutex<Core>>: a background worker polls the transport and handles inbound
payloads while the application thread issues outbound calls (send, create
conversation). Sharing the core across those two threads means moving it into
the spawned worker, which is only legal if it is Send. Access stays serialized
by the client's Mutex (one thread at a time), so the core needs Send but not
Sync and carries no lock of its own. See
docs/adr/0001-client-event-system.md for the background-poller design.
The Rc<RefCell> service-sharing is what made the core !Send. Context is de-Rc'd
and renamed to Core, owning its services outright and driving the inbox and
conversation primitives with plain &mut self.
- Services (identity, delivery, store, registry, MLS context, causal history)
are bundled into a ServiceContext<S> behind an ExternalServices trait, with
S = (DS, RS, CS). Constructors live on the (DS, RS, CS) form because S cannot
be inferred backwards through S::DS.
- Inbox, InboxV2, PrivateV1Convo, and GroupV1Convo become non-generic and
receive the ServiceContext bundle as a &mut/& parameter; no Rc or
RefCell-as-shared-state remains, so Core is Send whenever its injected
services are.
- Dispatch branches on ConversationKind in one place: Core rebuilds the target
as a Convo<S>/GroupConvo<S> trait object bound to the service bundle, so
conversations never escape the orchestrator.
- CausalHistoryStore drops its Rc, keeping a plain RefCell.
2026-06-08 21:55:33 +02:00
|
|
|
DS: DeliveryService + 'static,
|
|
|
|
|
RS: RegistrationService + 'static,
|
2026-06-15 13:15:18 -07:00
|
|
|
WS: WakeupService + 'static,
|
refactor(core): replace Rc-based Context with a synchronous, Send-able Core (#123)
Make the conversations core Send so the threaded client can own it behind an
Arc<Mutex<Core>>: a background worker polls the transport and handles inbound
payloads while the application thread issues outbound calls (send, create
conversation). Sharing the core across those two threads means moving it into
the spawned worker, which is only legal if it is Send. Access stays serialized
by the client's Mutex (one thread at a time), so the core needs Send but not
Sync and carries no lock of its own. See
docs/adr/0001-client-event-system.md for the background-poller design.
The Rc<RefCell> service-sharing is what made the core !Send. Context is de-Rc'd
and renamed to Core, owning its services outright and driving the inbox and
conversation primitives with plain &mut self.
- Services (identity, delivery, store, registry, MLS context, causal history)
are bundled into a ServiceContext<S> behind an ExternalServices trait, with
S = (DS, RS, CS). Constructors live on the (DS, RS, CS) form because S cannot
be inferred backwards through S::DS.
- Inbox, InboxV2, PrivateV1Convo, and GroupV1Convo become non-generic and
receive the ServiceContext bundle as a &mut/& parameter; no Rc or
RefCell-as-shared-state remains, so Core is Send whenever its injected
services are.
- Dispatch branches on ConversationKind in one place: Core rebuilds the target
as a Convo<S>/GroupConvo<S> trait object bound to the service bundle, so
conversations never escape the orchestrator.
- CausalHistoryStore drops its Rc, keeping a plain RefCell.
2026-06-08 21:55:33 +02:00
|
|
|
CS: ChatStore + 'static,
|
|
|
|
|
{
|
|
|
|
|
/// Opens or creates a `Core` with the given storage configuration.
|
|
|
|
|
///
|
|
|
|
|
/// If an identity exists in storage, it will be restored.
|
|
|
|
|
/// Otherwise, a new identity will be created with the given name and saved.
|
|
|
|
|
pub fn new_from_store(
|
2026-06-10 06:59:04 -07:00
|
|
|
ident: IP,
|
refactor(core): replace Rc-based Context with a synchronous, Send-able Core (#123)
Make the conversations core Send so the threaded client can own it behind an
Arc<Mutex<Core>>: a background worker polls the transport and handles inbound
payloads while the application thread issues outbound calls (send, create
conversation). Sharing the core across those two threads means moving it into
the spawned worker, which is only legal if it is Send. Access stays serialized
by the client's Mutex (one thread at a time), so the core needs Send but not
Sync and carries no lock of its own. See
docs/adr/0001-client-event-system.md for the background-poller design.
The Rc<RefCell> service-sharing is what made the core !Send. Context is de-Rc'd
and renamed to Core, owning its services outright and driving the inbox and
conversation primitives with plain &mut self.
- Services (identity, delivery, store, registry, MLS context, causal history)
are bundled into a ServiceContext<S> behind an ExternalServices trait, with
S = (DS, RS, CS). Constructors live on the (DS, RS, CS) form because S cannot
be inferred backwards through S::DS.
- Inbox, InboxV2, PrivateV1Convo, and GroupV1Convo become non-generic and
receive the ServiceContext bundle as a &mut/& parameter; no Rc or
RefCell-as-shared-state remains, so Core is Send whenever its injected
services are.
- Dispatch branches on ConversationKind in one place: Core rebuilds the target
as a Convo<S>/GroupConvo<S> trait object bound to the service bundle, so
conversations never escape the orchestrator.
- CausalHistoryStore drops its Rc, keeping a plain RefCell.
2026-06-08 21:55:33 +02:00
|
|
|
delivery: DS,
|
|
|
|
|
registration: RS,
|
2026-06-15 13:15:18 -07:00
|
|
|
wakeup_service: WS,
|
refactor(core): replace Rc-based Context with a synchronous, Send-able Core (#123)
Make the conversations core Send so the threaded client can own it behind an
Arc<Mutex<Core>>: a background worker polls the transport and handles inbound
payloads while the application thread issues outbound calls (send, create
conversation). Sharing the core across those two threads means moving it into
the spawned worker, which is only legal if it is Send. Access stays serialized
by the client's Mutex (one thread at a time), so the core needs Send but not
Sync and carries no lock of its own. See
docs/adr/0001-client-event-system.md for the background-poller design.
The Rc<RefCell> service-sharing is what made the core !Send. Context is de-Rc'd
and renamed to Core, owning its services outright and driving the inbox and
conversation primitives with plain &mut self.
- Services (identity, delivery, store, registry, MLS context, causal history)
are bundled into a ServiceContext<S> behind an ExternalServices trait, with
S = (DS, RS, CS). Constructors live on the (DS, RS, CS) form because S cannot
be inferred backwards through S::DS.
- Inbox, InboxV2, PrivateV1Convo, and GroupV1Convo become non-generic and
receive the ServiceContext bundle as a &mut/& parameter; no Rc or
RefCell-as-shared-state remains, so Core is Send whenever its injected
services are.
- Dispatch branches on ConversationKind in one place: Core rebuilds the target
as a Convo<S>/GroupConvo<S> trait object bound to the service bundle, so
conversations never escape the orchestrator.
- CausalHistoryStore drops its Rc, keeping a plain RefCell.
2026-06-08 21:55:33 +02:00
|
|
|
mut store: CS,
|
|
|
|
|
) -> Result<Self, ChatError> {
|
|
|
|
|
let identity = if let Some(identity) = store.load_identity()? {
|
|
|
|
|
identity
|
|
|
|
|
} else {
|
2026-06-10 06:59:04 -07:00
|
|
|
let identity = Identity::new(ident.id().as_str().to_string());
|
refactor(core): replace Rc-based Context with a synchronous, Send-able Core (#123)
Make the conversations core Send so the threaded client can own it behind an
Arc<Mutex<Core>>: a background worker polls the transport and handles inbound
payloads while the application thread issues outbound calls (send, create
conversation). Sharing the core across those two threads means moving it into
the spawned worker, which is only legal if it is Send. Access stays serialized
by the client's Mutex (one thread at a time), so the core needs Send but not
Sync and carries no lock of its own. See
docs/adr/0001-client-event-system.md for the background-poller design.
The Rc<RefCell> service-sharing is what made the core !Send. Context is de-Rc'd
and renamed to Core, owning its services outright and driving the inbox and
conversation primitives with plain &mut self.
- Services (identity, delivery, store, registry, MLS context, causal history)
are bundled into a ServiceContext<S> behind an ExternalServices trait, with
S = (DS, RS, CS). Constructors live on the (DS, RS, CS) form because S cannot
be inferred backwards through S::DS.
- Inbox, InboxV2, PrivateV1Convo, and GroupV1Convo become non-generic and
receive the ServiceContext bundle as a &mut/& parameter; no Rc or
RefCell-as-shared-state remains, so Core is Send whenever its injected
services are.
- Dispatch branches on ConversationKind in one place: Core rebuilds the target
as a Convo<S>/GroupConvo<S> trait object bound to the service bundle, so
conversations never escape the orchestrator.
- CausalHistoryStore drops its Rc, keeping a plain RefCell.
2026-06-08 21:55:33 +02:00
|
|
|
store.save_identity(&identity)?;
|
|
|
|
|
identity
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-15 13:15:18 -07:00
|
|
|
Self::assemble(
|
|
|
|
|
ident,
|
|
|
|
|
identity,
|
|
|
|
|
delivery,
|
|
|
|
|
registration,
|
|
|
|
|
wakeup_service,
|
|
|
|
|
store,
|
|
|
|
|
)
|
refactor(core): replace Rc-based Context with a synchronous, Send-able Core (#123)
Make the conversations core Send so the threaded client can own it behind an
Arc<Mutex<Core>>: a background worker polls the transport and handles inbound
payloads while the application thread issues outbound calls (send, create
conversation). Sharing the core across those two threads means moving it into
the spawned worker, which is only legal if it is Send. Access stays serialized
by the client's Mutex (one thread at a time), so the core needs Send but not
Sync and carries no lock of its own. See
docs/adr/0001-client-event-system.md for the background-poller design.
The Rc<RefCell> service-sharing is what made the core !Send. Context is de-Rc'd
and renamed to Core, owning its services outright and driving the inbox and
conversation primitives with plain &mut self.
- Services (identity, delivery, store, registry, MLS context, causal history)
are bundled into a ServiceContext<S> behind an ExternalServices trait, with
S = (DS, RS, CS). Constructors live on the (DS, RS, CS) form because S cannot
be inferred backwards through S::DS.
- Inbox, InboxV2, PrivateV1Convo, and GroupV1Convo become non-generic and
receive the ServiceContext bundle as a &mut/& parameter; no Rc or
RefCell-as-shared-state remains, so Core is Send whenever its injected
services are.
- Dispatch branches on ConversationKind in one place: Core rebuilds the target
as a Convo<S>/GroupConvo<S> trait object bound to the service bundle, so
conversations never escape the orchestrator.
- CausalHistoryStore drops its Rc, keeping a plain RefCell.
2026-06-08 21:55:33 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Creates a new in-memory `Core` (for testing).
|
|
|
|
|
///
|
|
|
|
|
/// Uses in-memory SQLite database. Each call creates a new isolated database.
|
|
|
|
|
pub fn new_with_name(
|
2026-06-10 06:59:04 -07:00
|
|
|
ident: IP,
|
refactor(core): replace Rc-based Context with a synchronous, Send-able Core (#123)
Make the conversations core Send so the threaded client can own it behind an
Arc<Mutex<Core>>: a background worker polls the transport and handles inbound
payloads while the application thread issues outbound calls (send, create
conversation). Sharing the core across those two threads means moving it into
the spawned worker, which is only legal if it is Send. Access stays serialized
by the client's Mutex (one thread at a time), so the core needs Send but not
Sync and carries no lock of its own. See
docs/adr/0001-client-event-system.md for the background-poller design.
The Rc<RefCell> service-sharing is what made the core !Send. Context is de-Rc'd
and renamed to Core, owning its services outright and driving the inbox and
conversation primitives with plain &mut self.
- Services (identity, delivery, store, registry, MLS context, causal history)
are bundled into a ServiceContext<S> behind an ExternalServices trait, with
S = (DS, RS, CS). Constructors live on the (DS, RS, CS) form because S cannot
be inferred backwards through S::DS.
- Inbox, InboxV2, PrivateV1Convo, and GroupV1Convo become non-generic and
receive the ServiceContext bundle as a &mut/& parameter; no Rc or
RefCell-as-shared-state remains, so Core is Send whenever its injected
services are.
- Dispatch branches on ConversationKind in one place: Core rebuilds the target
as a Convo<S>/GroupConvo<S> trait object bound to the service bundle, so
conversations never escape the orchestrator.
- CausalHistoryStore drops its Rc, keeping a plain RefCell.
2026-06-08 21:55:33 +02:00
|
|
|
delivery: DS,
|
|
|
|
|
registration: RS,
|
2026-06-15 13:15:18 -07:00
|
|
|
wakeup_service: WS,
|
2026-06-10 06:59:04 -07:00
|
|
|
store: CS,
|
refactor(core): replace Rc-based Context with a synchronous, Send-able Core (#123)
Make the conversations core Send so the threaded client can own it behind an
Arc<Mutex<Core>>: a background worker polls the transport and handles inbound
payloads while the application thread issues outbound calls (send, create
conversation). Sharing the core across those two threads means moving it into
the spawned worker, which is only legal if it is Send. Access stays serialized
by the client's Mutex (one thread at a time), so the core needs Send but not
Sync and carries no lock of its own. See
docs/adr/0001-client-event-system.md for the background-poller design.
The Rc<RefCell> service-sharing is what made the core !Send. Context is de-Rc'd
and renamed to Core, owning its services outright and driving the inbox and
conversation primitives with plain &mut self.
- Services (identity, delivery, store, registry, MLS context, causal history)
are bundled into a ServiceContext<S> behind an ExternalServices trait, with
S = (DS, RS, CS). Constructors live on the (DS, RS, CS) form because S cannot
be inferred backwards through S::DS.
- Inbox, InboxV2, PrivateV1Convo, and GroupV1Convo become non-generic and
receive the ServiceContext bundle as a &mut/& parameter; no Rc or
RefCell-as-shared-state remains, so Core is Send whenever its injected
services are.
- Dispatch branches on ConversationKind in one place: Core rebuilds the target
as a Convo<S>/GroupConvo<S> trait object bound to the service bundle, so
conversations never escape the orchestrator.
- CausalHistoryStore drops its Rc, keeping a plain RefCell.
2026-06-08 21:55:33 +02:00
|
|
|
) -> Result<Self, ChatError> {
|
2026-06-10 06:59:04 -07:00
|
|
|
let identity = Identity::new(ident.id().as_str().to_string());
|
2026-06-15 13:15:18 -07:00
|
|
|
let mut core = Self::assemble(
|
|
|
|
|
ident,
|
|
|
|
|
identity,
|
|
|
|
|
delivery,
|
|
|
|
|
registration,
|
|
|
|
|
wakeup_service,
|
|
|
|
|
store,
|
|
|
|
|
)?;
|
2026-06-10 06:59:04 -07:00
|
|
|
|
refactor(core): replace Rc-based Context with a synchronous, Send-able Core (#123)
Make the conversations core Send so the threaded client can own it behind an
Arc<Mutex<Core>>: a background worker polls the transport and handles inbound
payloads while the application thread issues outbound calls (send, create
conversation). Sharing the core across those two threads means moving it into
the spawned worker, which is only legal if it is Send. Access stays serialized
by the client's Mutex (one thread at a time), so the core needs Send but not
Sync and carries no lock of its own. See
docs/adr/0001-client-event-system.md for the background-poller design.
The Rc<RefCell> service-sharing is what made the core !Send. Context is de-Rc'd
and renamed to Core, owning its services outright and driving the inbox and
conversation primitives with plain &mut self.
- Services (identity, delivery, store, registry, MLS context, causal history)
are bundled into a ServiceContext<S> behind an ExternalServices trait, with
S = (DS, RS, CS). Constructors live on the (DS, RS, CS) form because S cannot
be inferred backwards through S::DS.
- Inbox, InboxV2, PrivateV1Convo, and GroupV1Convo become non-generic and
receive the ServiceContext bundle as a &mut/& parameter; no Rc or
RefCell-as-shared-state remains, so Core is Send whenever its injected
services are.
- Dispatch branches on ConversationKind in one place: Core rebuilds the target
as a Convo<S>/GroupConvo<S> trait object bound to the service bundle, so
conversations never escape the orchestrator.
- CausalHistoryStore drops its Rc, keeping a plain RefCell.
2026-06-08 21:55:33 +02:00
|
|
|
core.register_keypackage()?;
|
|
|
|
|
Ok(core)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-11 02:08:51 +03:00
|
|
|
pub fn set_group_v2_clock(&mut self, clock: GroupV2Clock) {
|
|
|
|
|
self.services.demls_clock = clock;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Overrides the GroupV2 (de-mls) timing/policy config. Applies to
|
|
|
|
|
/// conversations created/joined after the call; a creator's phase
|
|
|
|
|
/// durations reach joiners inside the welcome's `ConversationSync`.
|
|
|
|
|
pub fn set_group_v2_config(&mut self, config: GroupV2Config) {
|
|
|
|
|
self.services.demls_config = config;
|
|
|
|
|
}
|
|
|
|
|
|
refactor(core): replace Rc-based Context with a synchronous, Send-able Core (#123)
Make the conversations core Send so the threaded client can own it behind an
Arc<Mutex<Core>>: a background worker polls the transport and handles inbound
payloads while the application thread issues outbound calls (send, create
conversation). Sharing the core across those two threads means moving it into
the spawned worker, which is only legal if it is Send. Access stays serialized
by the client's Mutex (one thread at a time), so the core needs Send but not
Sync and carries no lock of its own. See
docs/adr/0001-client-event-system.md for the background-poller design.
The Rc<RefCell> service-sharing is what made the core !Send. Context is de-Rc'd
and renamed to Core, owning its services outright and driving the inbox and
conversation primitives with plain &mut self.
- Services (identity, delivery, store, registry, MLS context, causal history)
are bundled into a ServiceContext<S> behind an ExternalServices trait, with
S = (DS, RS, CS). Constructors live on the (DS, RS, CS) form because S cannot
be inferred backwards through S::DS.
- Inbox, InboxV2, PrivateV1Convo, and GroupV1Convo become non-generic and
receive the ServiceContext bundle as a &mut/& parameter; no Rc or
RefCell-as-shared-state remains, so Core is Send whenever its injected
services are.
- Dispatch branches on ConversationKind in one place: Core rebuilds the target
as a Convo<S>/GroupConvo<S> trait object bound to the service bundle, so
conversations never escape the orchestrator.
- CausalHistoryStore drops its Rc, keeping a plain RefCell.
2026-06-08 21:55:33 +02:00
|
|
|
/// Builds the inbox/account/MLS/causal state, subscribes both inbound
|
|
|
|
|
/// addresses, and assembles the service bundle — shared by both constructors.
|
|
|
|
|
fn assemble(
|
2026-06-10 06:59:04 -07:00
|
|
|
ident: IP,
|
refactor(core): replace Rc-based Context with a synchronous, Send-able Core (#123)
Make the conversations core Send so the threaded client can own it behind an
Arc<Mutex<Core>>: a background worker polls the transport and handles inbound
payloads while the application thread issues outbound calls (send, create
conversation). Sharing the core across those two threads means moving it into
the spawned worker, which is only legal if it is Send. Access stays serialized
by the client's Mutex (one thread at a time), so the core needs Send but not
Sync and carries no lock of its own. See
docs/adr/0001-client-event-system.md for the background-poller design.
The Rc<RefCell> service-sharing is what made the core !Send. Context is de-Rc'd
and renamed to Core, owning its services outright and driving the inbox and
conversation primitives with plain &mut self.
- Services (identity, delivery, store, registry, MLS context, causal history)
are bundled into a ServiceContext<S> behind an ExternalServices trait, with
S = (DS, RS, CS). Constructors live on the (DS, RS, CS) form because S cannot
be inferred backwards through S::DS.
- Inbox, InboxV2, PrivateV1Convo, and GroupV1Convo become non-generic and
receive the ServiceContext bundle as a &mut/& parameter; no Rc or
RefCell-as-shared-state remains, so Core is Send whenever its injected
services are.
- Dispatch branches on ConversationKind in one place: Core rebuilds the target
as a Convo<S>/GroupConvo<S> trait object bound to the service bundle, so
conversations never escape the orchestrator.
- CausalHistoryStore drops its Rc, keeping a plain RefCell.
2026-06-08 21:55:33 +02:00
|
|
|
identity: Identity,
|
|
|
|
|
mut delivery: DS,
|
|
|
|
|
registration: RS,
|
2026-06-15 13:15:18 -07:00
|
|
|
wakeup_service: WS,
|
refactor(core): replace Rc-based Context with a synchronous, Send-able Core (#123)
Make the conversations core Send so the threaded client can own it behind an
Arc<Mutex<Core>>: a background worker polls the transport and handles inbound
payloads while the application thread issues outbound calls (send, create
conversation). Sharing the core across those two threads means moving it into
the spawned worker, which is only legal if it is Send. Access stays serialized
by the client's Mutex (one thread at a time), so the core needs Send but not
Sync and carries no lock of its own. See
docs/adr/0001-client-event-system.md for the background-poller design.
The Rc<RefCell> service-sharing is what made the core !Send. Context is de-Rc'd
and renamed to Core, owning its services outright and driving the inbox and
conversation primitives with plain &mut self.
- Services (identity, delivery, store, registry, MLS context, causal history)
are bundled into a ServiceContext<S> behind an ExternalServices trait, with
S = (DS, RS, CS). Constructors live on the (DS, RS, CS) form because S cannot
be inferred backwards through S::DS.
- Inbox, InboxV2, PrivateV1Convo, and GroupV1Convo become non-generic and
receive the ServiceContext bundle as a &mut/& parameter; no Rc or
RefCell-as-shared-state remains, so Core is Send whenever its injected
services are.
- Dispatch branches on ConversationKind in one place: Core rebuilds the target
as a Convo<S>/GroupConvo<S> trait object bound to the service bundle, so
conversations never escape the orchestrator.
- CausalHistoryStore drops its Rc, keeping a plain RefCell.
2026-06-08 21:55:33 +02:00
|
|
|
store: CS,
|
|
|
|
|
) -> Result<Self, ChatError> {
|
|
|
|
|
let inbox = Inbox::new(&identity);
|
2026-07-03 23:18:10 +02:00
|
|
|
// InboxV2 rendezvous is signer-scoped: it subscribes under the hex of
|
|
|
|
|
// the signer's verifying key — the same string the account → device
|
|
|
|
|
// directory lists and the registries key key-packages under, so it is
|
|
|
|
|
// exactly what an inviter can derive for this installation. The MLS
|
|
|
|
|
// credential below still carries the full `id()`.
|
|
|
|
|
let ident_id = IdentId::new(hex::encode(ident.public_key().as_ref()));
|
2026-06-10 06:59:04 -07:00
|
|
|
let mls_identity = MlsIdentityProvider::new(ident);
|
refactor(core): replace Rc-based Context with a synchronous, Send-able Core (#123)
Make the conversations core Send so the threaded client can own it behind an
Arc<Mutex<Core>>: a background worker polls the transport and handles inbound
payloads while the application thread issues outbound calls (send, create
conversation). Sharing the core across those two threads means moving it into
the spawned worker, which is only legal if it is Send. Access stays serialized
by the client's Mutex (one thread at a time), so the core needs Send but not
Sync and carries no lock of its own. See
docs/adr/0001-client-event-system.md for the background-poller design.
The Rc<RefCell> service-sharing is what made the core !Send. Context is de-Rc'd
and renamed to Core, owning its services outright and driving the inbox and
conversation primitives with plain &mut self.
- Services (identity, delivery, store, registry, MLS context, causal history)
are bundled into a ServiceContext<S> behind an ExternalServices trait, with
S = (DS, RS, CS). Constructors live on the (DS, RS, CS) form because S cannot
be inferred backwards through S::DS.
- Inbox, InboxV2, PrivateV1Convo, and GroupV1Convo become non-generic and
receive the ServiceContext bundle as a &mut/& parameter; no Rc or
RefCell-as-shared-state remains, so Core is Send whenever its injected
services are.
- Dispatch branches on ConversationKind in one place: Core rebuilds the target
as a Convo<S>/GroupConvo<S> trait object bound to the service bundle, so
conversations never escape the orchestrator.
- CausalHistoryStore drops its Rc, keeping a plain RefCell.
2026-06-08 21:55:33 +02:00
|
|
|
let mls_provider = MlsEphemeralPqProvider::new().map_err(ChatError::generic)?;
|
|
|
|
|
let causal = CausalHistoryStore::new();
|
2026-06-10 06:59:04 -07:00
|
|
|
let pq_inbox = InboxV2::new(ident_id);
|
refactor(core): replace Rc-based Context with a synchronous, Send-able Core (#123)
Make the conversations core Send so the threaded client can own it behind an
Arc<Mutex<Core>>: a background worker polls the transport and handles inbound
payloads while the application thread issues outbound calls (send, create
conversation). Sharing the core across those two threads means moving it into
the spawned worker, which is only legal if it is Send. Access stays serialized
by the client's Mutex (one thread at a time), so the core needs Send but not
Sync and carries no lock of its own. See
docs/adr/0001-client-event-system.md for the background-poller design.
The Rc<RefCell> service-sharing is what made the core !Send. Context is de-Rc'd
and renamed to Core, owning its services outright and driving the inbox and
conversation primitives with plain &mut self.
- Services (identity, delivery, store, registry, MLS context, causal history)
are bundled into a ServiceContext<S> behind an ExternalServices trait, with
S = (DS, RS, CS). Constructors live on the (DS, RS, CS) form because S cannot
be inferred backwards through S::DS.
- Inbox, InboxV2, PrivateV1Convo, and GroupV1Convo become non-generic and
receive the ServiceContext bundle as a &mut/& parameter; no Rc or
RefCell-as-shared-state remains, so Core is Send whenever its injected
services are.
- Dispatch branches on ConversationKind in one place: Core rebuilds the target
as a Convo<S>/GroupConvo<S> trait object bound to the service bundle, so
conversations never escape the orchestrator.
- CausalHistoryStore drops its Rc, keeping a plain RefCell.
2026-06-08 21:55:33 +02:00
|
|
|
|
|
|
|
|
// Subscribe to inbound addresses for both conversation stacks.
|
|
|
|
|
delivery
|
|
|
|
|
.subscribe(inbox.delivery_address())
|
|
|
|
|
.map_err(ChatError::generic)?;
|
|
|
|
|
delivery
|
|
|
|
|
.subscribe(&pq_inbox.delivery_address())
|
|
|
|
|
.map_err(ChatError::generic)?;
|
|
|
|
|
|
|
|
|
|
Ok(Self {
|
|
|
|
|
services: ServiceContext {
|
|
|
|
|
ds: delivery,
|
|
|
|
|
registry: registration,
|
|
|
|
|
store,
|
|
|
|
|
mls_identity,
|
|
|
|
|
mls_provider,
|
|
|
|
|
causal,
|
|
|
|
|
identity,
|
2026-06-15 13:15:18 -07:00
|
|
|
wakeup_service,
|
2026-07-11 02:08:51 +03:00
|
|
|
demls_clock: GroupV2Clock::default(),
|
|
|
|
|
demls_config: GroupV2Config::default(),
|
refactor(core): replace Rc-based Context with a synchronous, Send-able Core (#123)
Make the conversations core Send so the threaded client can own it behind an
Arc<Mutex<Core>>: a background worker polls the transport and handles inbound
payloads while the application thread issues outbound calls (send, create
conversation). Sharing the core across those two threads means moving it into
the spawned worker, which is only legal if it is Send. Access stays serialized
by the client's Mutex (one thread at a time), so the core needs Send but not
Sync and carries no lock of its own. See
docs/adr/0001-client-event-system.md for the background-poller design.
The Rc<RefCell> service-sharing is what made the core !Send. Context is de-Rc'd
and renamed to Core, owning its services outright and driving the inbox and
conversation primitives with plain &mut self.
- Services (identity, delivery, store, registry, MLS context, causal history)
are bundled into a ServiceContext<S> behind an ExternalServices trait, with
S = (DS, RS, CS). Constructors live on the (DS, RS, CS) form because S cannot
be inferred backwards through S::DS.
- Inbox, InboxV2, PrivateV1Convo, and GroupV1Convo become non-generic and
receive the ServiceContext bundle as a &mut/& parameter; no Rc or
RefCell-as-shared-state remains, so Core is Send whenever its injected
services are.
- Dispatch branches on ConversationKind in one place: Core rebuilds the target
as a Convo<S>/GroupConvo<S> trait object bound to the service bundle, so
conversations never escape the orchestrator.
- CausalHistoryStore drops its Rc, keeping a plain RefCell.
2026-06-08 21:55:33 +02:00
|
|
|
},
|
|
|
|
|
inbox,
|
|
|
|
|
pq_inbox,
|
2026-06-15 13:15:18 -07:00
|
|
|
cached_convos: HashMap::new(),
|
refactor(core): replace Rc-based Context with a synchronous, Send-able Core (#123)
Make the conversations core Send so the threaded client can own it behind an
Arc<Mutex<Core>>: a background worker polls the transport and handles inbound
payloads while the application thread issues outbound calls (send, create
conversation). Sharing the core across those two threads means moving it into
the spawned worker, which is only legal if it is Send. Access stays serialized
by the client's Mutex (one thread at a time), so the core needs Send but not
Sync and carries no lock of its own. See
docs/adr/0001-client-event-system.md for the background-poller design.
The Rc<RefCell> service-sharing is what made the core !Send. Context is de-Rc'd
and renamed to Core, owning its services outright and driving the inbox and
conversation primitives with plain &mut self.
- Services (identity, delivery, store, registry, MLS context, causal history)
are bundled into a ServiceContext<S> behind an ExternalServices trait, with
S = (DS, RS, CS). Constructors live on the (DS, RS, CS) form because S cannot
be inferred backwards through S::DS.
- Inbox, InboxV2, PrivateV1Convo, and GroupV1Convo become non-generic and
receive the ServiceContext bundle as a &mut/& parameter; no Rc or
RefCell-as-shared-state remains, so Core is Send whenever its injected
services are.
- Dispatch branches on ConversationKind in one place: Core rebuilds the target
as a Convo<S>/GroupConvo<S> trait object bound to the service bundle, so
conversations never escape the orchestrator.
- CausalHistoryStore drops its Rc, keeping a plain RefCell.
2026-06-08 21:55:33 +02:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-10 06:59:04 -07:00
|
|
|
impl<'a, S: ExternalServices + 'static> Core<S> {
|
refactor(core): replace Rc-based Context with a synchronous, Send-able Core (#123)
Make the conversations core Send so the threaded client can own it behind an
Arc<Mutex<Core>>: a background worker polls the transport and handles inbound
payloads while the application thread issues outbound calls (send, create
conversation). Sharing the core across those two threads means moving it into
the spawned worker, which is only legal if it is Send. Access stays serialized
by the client's Mutex (one thread at a time), so the core needs Send but not
Sync and carries no lock of its own. See
docs/adr/0001-client-event-system.md for the background-poller design.
The Rc<RefCell> service-sharing is what made the core !Send. Context is de-Rc'd
and renamed to Core, owning its services outright and driving the inbox and
conversation primitives with plain &mut self.
- Services (identity, delivery, store, registry, MLS context, causal history)
are bundled into a ServiceContext<S> behind an ExternalServices trait, with
S = (DS, RS, CS). Constructors live on the (DS, RS, CS) form because S cannot
be inferred backwards through S::DS.
- Inbox, InboxV2, PrivateV1Convo, and GroupV1Convo become non-generic and
receive the ServiceContext bundle as a &mut/& parameter; no Rc or
RefCell-as-shared-state remains, so Core is Send whenever its injected
services are.
- Dispatch branches on ConversationKind in one place: Core rebuilds the target
as a Convo<S>/GroupConvo<S> trait object bound to the service bundle, so
conversations never escape the orchestrator.
- CausalHistoryStore drops its Rc, keeping a plain RefCell.
2026-06-08 21:55:33 +02:00
|
|
|
pub fn ds(&mut self) -> &mut S::DS {
|
|
|
|
|
&mut self.services.ds
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn store(&self) -> &S::CS {
|
|
|
|
|
&self.services.store
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn identity(&self) -> &Identity {
|
|
|
|
|
&self.services.identity
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-03 23:18:10 +02:00
|
|
|
/// The signer id this core receives InboxV2 invites under — the hex of the
|
|
|
|
|
/// signer's verifying key.
|
2026-06-10 06:59:04 -07:00
|
|
|
pub fn ident_id(&'a self) -> IdentIdRef<'a> {
|
|
|
|
|
self.pq_inbox.ident_id()
|
refactor(core): replace Rc-based Context with a synchronous, Send-able Core (#123)
Make the conversations core Send so the threaded client can own it behind an
Arc<Mutex<Core>>: a background worker polls the transport and handles inbound
payloads while the application thread issues outbound calls (send, create
conversation). Sharing the core across those two threads means moving it into
the spawned worker, which is only legal if it is Send. Access stays serialized
by the client's Mutex (one thread at a time), so the core needs Send but not
Sync and carries no lock of its own. See
docs/adr/0001-client-event-system.md for the background-poller design.
The Rc<RefCell> service-sharing is what made the core !Send. Context is de-Rc'd
and renamed to Core, owning its services outright and driving the inbox and
conversation primitives with plain &mut self.
- Services (identity, delivery, store, registry, MLS context, causal history)
are bundled into a ServiceContext<S> behind an ExternalServices trait, with
S = (DS, RS, CS). Constructors live on the (DS, RS, CS) form because S cannot
be inferred backwards through S::DS.
- Inbox, InboxV2, PrivateV1Convo, and GroupV1Convo become non-generic and
receive the ServiceContext bundle as a &mut/& parameter; no Rc or
RefCell-as-shared-state remains, so Core is Send whenever its injected
services are.
- Dispatch branches on ConversationKind in one place: Core rebuilds the target
as a Convo<S>/GroupConvo<S> trait object bound to the service bundle, so
conversations never escape the orchestrator.
- CausalHistoryStore drops its Rc, keeping a plain RefCell.
2026-06-08 21:55:33 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Submit the local account's MLS KeyPackage to the registration service.
|
|
|
|
|
/// Idempotent on the server side (registries that retain history will keep
|
|
|
|
|
/// the most recent N submissions; older entries are pruned).
|
|
|
|
|
pub fn register_keypackage(&mut self) -> Result<(), ChatError> {
|
|
|
|
|
self.pq_inbox.register(&mut self.services)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn installation_name(&self) -> &str {
|
|
|
|
|
self.services.identity.get_name()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn installation_key(&self) -> PublicKey {
|
|
|
|
|
self.services.identity.public_key()
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-20 09:44:27 -07:00
|
|
|
pub fn create_direct_convo(
|
refactor(core): replace Rc-based Context with a synchronous, Send-able Core (#123)
Make the conversations core Send so the threaded client can own it behind an
Arc<Mutex<Core>>: a background worker polls the transport and handles inbound
payloads while the application thread issues outbound calls (send, create
conversation). Sharing the core across those two threads means moving it into
the spawned worker, which is only legal if it is Send. Access stays serialized
by the client's Mutex (one thread at a time), so the core needs Send but not
Sync and carries no lock of its own. See
docs/adr/0001-client-event-system.md for the background-poller design.
The Rc<RefCell> service-sharing is what made the core !Send. Context is de-Rc'd
and renamed to Core, owning its services outright and driving the inbox and
conversation primitives with plain &mut self.
- Services (identity, delivery, store, registry, MLS context, causal history)
are bundled into a ServiceContext<S> behind an ExternalServices trait, with
S = (DS, RS, CS). Constructors live on the (DS, RS, CS) form because S cannot
be inferred backwards through S::DS.
- Inbox, InboxV2, PrivateV1Convo, and GroupV1Convo become non-generic and
receive the ServiceContext bundle as a &mut/& parameter; no Rc or
RefCell-as-shared-state remains, so Core is Send whenever its injected
services are.
- Dispatch branches on ConversationKind in one place: Core rebuilds the target
as a Convo<S>/GroupConvo<S> trait object bound to the service bundle, so
conversations never escape the orchestrator.
- CausalHistoryStore drops its Rc, keeping a plain RefCell.
2026-06-08 21:55:33 +02:00
|
|
|
&mut self,
|
2026-06-20 09:44:27 -07:00
|
|
|
members: &[IdentIdRef],
|
2026-06-19 12:01:17 -07:00
|
|
|
) -> Result<ConversationId, ChatError> {
|
2026-06-20 09:44:27 -07:00
|
|
|
self.create_direct_convo_v1(members)
|
2026-06-19 12:01:17 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn create_private_convo_v1(
|
|
|
|
|
&mut self,
|
|
|
|
|
remote_bundle: &Introduction,
|
|
|
|
|
content: &[u8],
|
refactor(core): replace Rc-based Context with a synchronous, Send-able Core (#123)
Make the conversations core Send so the threaded client can own it behind an
Arc<Mutex<Core>>: a background worker polls the transport and handles inbound
payloads while the application thread issues outbound calls (send, create
conversation). Sharing the core across those two threads means moving it into
the spawned worker, which is only legal if it is Send. Access stays serialized
by the client's Mutex (one thread at a time), so the core needs Send but not
Sync and carries no lock of its own. See
docs/adr/0001-client-event-system.md for the background-poller design.
The Rc<RefCell> service-sharing is what made the core !Send. Context is de-Rc'd
and renamed to Core, owning its services outright and driving the inbox and
conversation primitives with plain &mut self.
- Services (identity, delivery, store, registry, MLS context, causal history)
are bundled into a ServiceContext<S> behind an ExternalServices trait, with
S = (DS, RS, CS). Constructors live on the (DS, RS, CS) form because S cannot
be inferred backwards through S::DS.
- Inbox, InboxV2, PrivateV1Convo, and GroupV1Convo become non-generic and
receive the ServiceContext bundle as a &mut/& parameter; no Rc or
RefCell-as-shared-state remains, so Core is Send whenever its injected
services are.
- Dispatch branches on ConversationKind in one place: Core rebuilds the target
as a Convo<S>/GroupConvo<S> trait object bound to the service bundle, so
conversations never escape the orchestrator.
- CausalHistoryStore drops its Rc, keeping a plain RefCell.
2026-06-08 21:55:33 +02:00
|
|
|
) -> Result<ConversationId, ChatError> {
|
|
|
|
|
let (mut convo, payloads) =
|
|
|
|
|
self.inbox
|
|
|
|
|
.invite_to_private_convo(&mut self.services, remote_bundle, content)?;
|
|
|
|
|
|
|
|
|
|
let remote_id = Inbox::inbox_identifier_for_key(*remote_bundle.installation_key());
|
|
|
|
|
let convo_id = convo.persist(&mut self.services.store)?;
|
|
|
|
|
for payload in payloads {
|
|
|
|
|
self.services
|
|
|
|
|
.ds
|
|
|
|
|
.publish(payload.into_envelope(remote_id.clone()))
|
|
|
|
|
.map_err(|e| ChatError::Delivery(e.to_string()))?;
|
|
|
|
|
}
|
|
|
|
|
Ok(convo_id)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-19 12:01:17 -07:00
|
|
|
pub fn create_direct_convo_v1(
|
|
|
|
|
&mut self,
|
|
|
|
|
members: &[IdentIdRef],
|
|
|
|
|
) -> Result<ConversationId, ChatError> {
|
|
|
|
|
let convo = DirectV1Convo::new(&mut self.services, members)?;
|
|
|
|
|
let convo_id = convo.id().to_string();
|
|
|
|
|
self.register_convo(ConvoTypeOwned::Direct(Box::new(convo)))?;
|
|
|
|
|
|
|
|
|
|
Ok(convo_id)
|
|
|
|
|
}
|
|
|
|
|
|
refactor(core): replace Rc-based Context with a synchronous, Send-able Core (#123)
Make the conversations core Send so the threaded client can own it behind an
Arc<Mutex<Core>>: a background worker polls the transport and handles inbound
payloads while the application thread issues outbound calls (send, create
conversation). Sharing the core across those two threads means moving it into
the spawned worker, which is only legal if it is Send. Access stays serialized
by the client's Mutex (one thread at a time), so the core needs Send but not
Sync and carries no lock of its own. See
docs/adr/0001-client-event-system.md for the background-poller design.
The Rc<RefCell> service-sharing is what made the core !Send. Context is de-Rc'd
and renamed to Core, owning its services outright and driving the inbox and
conversation primitives with plain &mut self.
- Services (identity, delivery, store, registry, MLS context, causal history)
are bundled into a ServiceContext<S> behind an ExternalServices trait, with
S = (DS, RS, CS). Constructors live on the (DS, RS, CS) form because S cannot
be inferred backwards through S::DS.
- Inbox, InboxV2, PrivateV1Convo, and GroupV1Convo become non-generic and
receive the ServiceContext bundle as a &mut/& parameter; no Rc or
RefCell-as-shared-state remains, so Core is Send whenever its injected
services are.
- Dispatch branches on ConversationKind in one place: Core rebuilds the target
as a Convo<S>/GroupConvo<S> trait object bound to the service bundle, so
conversations never escape the orchestrator.
- CausalHistoryStore drops its Rc, keeping a plain RefCell.
2026-06-08 21:55:33 +02:00
|
|
|
pub fn create_group_convo(
|
|
|
|
|
&mut self,
|
2026-06-10 06:59:04 -07:00
|
|
|
participants: &[IdentIdRef],
|
2026-06-15 13:15:18 -07:00
|
|
|
) -> Result<ConversationId, ChatError> {
|
2026-07-10 23:26:05 -07:00
|
|
|
self.create_group_convo_v2(participants, "", "")
|
2026-06-15 13:15:18 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn create_group_convo_v1(
|
|
|
|
|
&mut self,
|
|
|
|
|
participants: &[IdentIdRef],
|
refactor(core): replace Rc-based Context with a synchronous, Send-able Core (#123)
Make the conversations core Send so the threaded client can own it behind an
Arc<Mutex<Core>>: a background worker polls the transport and handles inbound
payloads while the application thread issues outbound calls (send, create
conversation). Sharing the core across those two threads means moving it into
the spawned worker, which is only legal if it is Send. Access stays serialized
by the client's Mutex (one thread at a time), so the core needs Send but not
Sync and carries no lock of its own. See
docs/adr/0001-client-event-system.md for the background-poller design.
The Rc<RefCell> service-sharing is what made the core !Send. Context is de-Rc'd
and renamed to Core, owning its services outright and driving the inbox and
conversation primitives with plain &mut self.
- Services (identity, delivery, store, registry, MLS context, causal history)
are bundled into a ServiceContext<S> behind an ExternalServices trait, with
S = (DS, RS, CS). Constructors live on the (DS, RS, CS) form because S cannot
be inferred backwards through S::DS.
- Inbox, InboxV2, PrivateV1Convo, and GroupV1Convo become non-generic and
receive the ServiceContext bundle as a &mut/& parameter; no Rc or
RefCell-as-shared-state remains, so Core is Send whenever its injected
services are.
- Dispatch branches on ConversationKind in one place: Core rebuilds the target
as a Convo<S>/GroupConvo<S> trait object bound to the service bundle, so
conversations never escape the orchestrator.
- CausalHistoryStore drops its Rc, keeping a plain RefCell.
2026-06-08 21:55:33 +02:00
|
|
|
) -> Result<ConversationId, ChatError> {
|
|
|
|
|
// TODO: (P1) Ensure errors are handled properly. This is a high chance for
|
|
|
|
|
// desynchronized state: MlsGroup persistence, conversation persistence, and
|
|
|
|
|
// invite delivery all happen separately.
|
|
|
|
|
let mut convo = GroupV1Convo::new(&mut self.services)?;
|
|
|
|
|
self.services
|
|
|
|
|
.store
|
|
|
|
|
.save_conversation(&storage::ConversationMeta {
|
|
|
|
|
local_convo_id: convo.id().to_string(),
|
|
|
|
|
remote_convo_id: "0".into(),
|
|
|
|
|
kind: ConversationKind::GroupV1,
|
|
|
|
|
})?;
|
|
|
|
|
convo.add_member(&mut self.services, participants)?;
|
2026-06-15 13:15:18 -07:00
|
|
|
let convo_id = convo.id().to_string();
|
|
|
|
|
|
|
|
|
|
self.register_convo(ConvoTypeOwned::Group(Box::new(convo)))?;
|
|
|
|
|
|
|
|
|
|
Ok(convo_id)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn create_group_convo_v2(
|
|
|
|
|
&mut self,
|
|
|
|
|
participants: &[IdentIdRef],
|
2026-07-10 23:26:05 -07:00
|
|
|
name: &str,
|
|
|
|
|
desc: &str,
|
2026-06-15 13:15:18 -07:00
|
|
|
) -> Result<ConversationId, ChatError> {
|
|
|
|
|
// TODO: (P1) Ensure errors are handled properly. This is a high chance for
|
|
|
|
|
// desynchronized state: MlsGroup persistence, conversation persistence, and
|
|
|
|
|
// invite delivery all happen separately.
|
2026-07-10 23:26:05 -07:00
|
|
|
let mut convo = GroupV2Convo::new(&mut self.services, name, desc)?;
|
2026-06-15 13:15:18 -07:00
|
|
|
convo.add_member(&mut self.services, participants)?;
|
|
|
|
|
let convo_id = convo.id().to_string();
|
|
|
|
|
|
|
|
|
|
self.register_convo(ConvoTypeOwned::Group(Box::new(convo)))?;
|
|
|
|
|
|
|
|
|
|
Ok(convo_id)
|
refactor(core): replace Rc-based Context with a synchronous, Send-able Core (#123)
Make the conversations core Send so the threaded client can own it behind an
Arc<Mutex<Core>>: a background worker polls the transport and handles inbound
payloads while the application thread issues outbound calls (send, create
conversation). Sharing the core across those two threads means moving it into
the spawned worker, which is only legal if it is Send. Access stays serialized
by the client's Mutex (one thread at a time), so the core needs Send but not
Sync and carries no lock of its own. See
docs/adr/0001-client-event-system.md for the background-poller design.
The Rc<RefCell> service-sharing is what made the core !Send. Context is de-Rc'd
and renamed to Core, owning its services outright and driving the inbox and
conversation primitives with plain &mut self.
- Services (identity, delivery, store, registry, MLS context, causal history)
are bundled into a ServiceContext<S> behind an ExternalServices trait, with
S = (DS, RS, CS). Constructors live on the (DS, RS, CS) form because S cannot
be inferred backwards through S::DS.
- Inbox, InboxV2, PrivateV1Convo, and GroupV1Convo become non-generic and
receive the ServiceContext bundle as a &mut/& parameter; no Rc or
RefCell-as-shared-state remains, so Core is Send whenever its injected
services are.
- Dispatch branches on ConversationKind in one place: Core rebuilds the target
as a Convo<S>/GroupConvo<S> trait object bound to the service bundle, so
conversations never escape the orchestrator.
- CausalHistoryStore drops its Rc, keeping a plain RefCell.
2026-06-08 21:55:33 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Add members to an existing group conversation.
|
|
|
|
|
pub fn group_add_member(
|
|
|
|
|
&mut self,
|
|
|
|
|
convo_id: &str,
|
2026-06-10 06:59:04 -07:00
|
|
|
members: &[IdentIdRef],
|
refactor(core): replace Rc-based Context with a synchronous, Send-able Core (#123)
Make the conversations core Send so the threaded client can own it behind an
Arc<Mutex<Core>>: a background worker polls the transport and handles inbound
payloads while the application thread issues outbound calls (send, create
conversation). Sharing the core across those two threads means moving it into
the spawned worker, which is only legal if it is Send. Access stays serialized
by the client's Mutex (one thread at a time), so the core needs Send but not
Sync and carries no lock of its own. See
docs/adr/0001-client-event-system.md for the background-poller design.
The Rc<RefCell> service-sharing is what made the core !Send. Context is de-Rc'd
and renamed to Core, owning its services outright and driving the inbox and
conversation primitives with plain &mut self.
- Services (identity, delivery, store, registry, MLS context, causal history)
are bundled into a ServiceContext<S> behind an ExternalServices trait, with
S = (DS, RS, CS). Constructors live on the (DS, RS, CS) form because S cannot
be inferred backwards through S::DS.
- Inbox, InboxV2, PrivateV1Convo, and GroupV1Convo become non-generic and
receive the ServiceContext bundle as a &mut/& parameter; no Rc or
RefCell-as-shared-state remains, so Core is Send whenever its injected
services are.
- Dispatch branches on ConversationKind in one place: Core rebuilds the target
as a Convo<S>/GroupConvo<S> trait object bound to the service bundle, so
conversations never escape the orchestrator.
- CausalHistoryStore drops its Rc, keeping a plain RefCell.
2026-06-08 21:55:33 +02:00
|
|
|
) -> Result<(), ChatError> {
|
2026-06-15 13:15:18 -07:00
|
|
|
if self.cached_convos.contains_key(convo_id) {
|
|
|
|
|
let convo = self
|
|
|
|
|
.cached_convos
|
|
|
|
|
.get_mut(convo_id)
|
|
|
|
|
.ok_or_else(|| ChatError::NoConvo(convo_id.to_string()))?;
|
|
|
|
|
|
|
|
|
|
match convo {
|
|
|
|
|
ConvoTypeOwned::Group(group_convo) => {
|
|
|
|
|
group_convo.add_member(&mut self.services, members)
|
|
|
|
|
}
|
2026-06-19 12:01:17 -07:00
|
|
|
ConvoTypeOwned::Direct(convo) => Err(ChatError::UnsupportedFunction(
|
|
|
|
|
convo.id().into(),
|
|
|
|
|
"Add Member".into(),
|
|
|
|
|
)),
|
2026-06-15 13:15:18 -07:00
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
let mut convo = self.load_group_convo(convo_id)?;
|
|
|
|
|
convo.add_member(&mut self.services, members)
|
|
|
|
|
}
|
refactor(core): replace Rc-based Context with a synchronous, Send-able Core (#123)
Make the conversations core Send so the threaded client can own it behind an
Arc<Mutex<Core>>: a background worker polls the transport and handles inbound
payloads while the application thread issues outbound calls (send, create
conversation). Sharing the core across those two threads means moving it into
the spawned worker, which is only legal if it is Send. Access stays serialized
by the client's Mutex (one thread at a time), so the core needs Send but not
Sync and carries no lock of its own. See
docs/adr/0001-client-event-system.md for the background-poller design.
The Rc<RefCell> service-sharing is what made the core !Send. Context is de-Rc'd
and renamed to Core, owning its services outright and driving the inbox and
conversation primitives with plain &mut self.
- Services (identity, delivery, store, registry, MLS context, causal history)
are bundled into a ServiceContext<S> behind an ExternalServices trait, with
S = (DS, RS, CS). Constructors live on the (DS, RS, CS) form because S cannot
be inferred backwards through S::DS.
- Inbox, InboxV2, PrivateV1Convo, and GroupV1Convo become non-generic and
receive the ServiceContext bundle as a &mut/& parameter; no Rc or
RefCell-as-shared-state remains, so Core is Send whenever its injected
services are.
- Dispatch branches on ConversationKind in one place: Core rebuilds the target
as a Convo<S>/GroupConvo<S> trait object bound to the service bundle, so
conversations never escape the orchestrator.
- CausalHistoryStore drops its Rc, keeping a plain RefCell.
2026-06-08 21:55:33 +02:00
|
|
|
}
|
|
|
|
|
|
feat: GroupV2 through the threaded client, group roster, registry retry (#167)
* feat: expose GroupV2 through the threaded client
GroupV2 (de-mls) conversations were reachable only from Core, and every
conversation ran the hardcoded millisecond timer profile in group_v2.rs
(20-150 ms freeze/consensus windows), which cannot survive real network
latency. Make them reachable through ChatClient (as DirectV1 already is), with
timing that holds up over a real network.
- ChatClient::create_group_conversation(accounts) and
add_group_members(convo_id, accounts): resolve each account address to
its endorsed signer ids through the client-held directory, the same
resolution create_direct_conversation uses, and drive
Core::create_group_convo / group_add_member.
- GroupV2 timing/policy is injectable: ServiceContext carries a
de_mls::ConversationConfig (re-exported as GroupV2Config) defaulting
to the de-mls library defaults; Core::set_group_v2_config and the
builder's group_v2_config setter override it. The creator's phase
durations reach joiners inside the welcome's ConversationSync, so the
group runs the creator's phase timing.
- GroupV2Convo::add_member validates every member's key package before
proposing any add, skips members de-mls would silently not propose
(self, already in the group) instead of stranding a pending invite,
and flushes opened proposals even on a mid-batch failure, so a failed
batch cannot invite members behind the caller's back.
- The millisecond test profile moves into the test harnesses
(integration_tests_core's TestHarness, crates/client/tests/group_v2.rs).
- New client-level tests: three accounts on the in-process transport
create a group, a non-creator adds the third member, and messages fan
out with directory-verified senders; a batch containing a member with
no key package fails without inviting anyone.
* fix: class inbound DirectV1 joins as Private, not Group
The joiner of a DirectV1 (pairwise) conversation received it classed as
Group, because dispatch_to_inbox2 hardcoded ConversationClass::Group for
every InboxV2 join. DirectV1 welcomes (InviteType::GroupV1) and GroupV2
welcomes (InviteType::GroupV2) both arrive over InboxV2, so a plain 1:1
invite surfaced to the display layer as a group. ConversationClass is
documented as stable across protocol versions of the same conversation
shape, and DirectV1 is the pairwise shape, so its joiner must see Private.
- InboxV2::handle_frame returns the class alongside the convo:
InviteType::GroupV1 (the DirectV1 welcome carrier) yields Private,
InviteType::GroupV2 yields Group.
- dispatch_to_inbox2 propagates that class instead of hardcoding Group.
- direct_v1_by_account_address asserts the joiner sees Private.
* feat: expose a group's roster, deduped to one entry per account
The display layer needs a group's membership, but nothing exposed it:
de-mls holds the authoritative roster (MLS group state) with no public
accessor, and members added by other members stay invisible until they
send a message. Rebuilding the roster from observed messages would fork
state the crypto layer owns and be wrong exactly when a group grows.
- GroupConvo::members() returns each member's hex-encoded MLS
leaf-credential content, self included. GroupV2Convo delegates to
de-mls and guarantees self-inclusion; GroupV1Convo reads its openmls
leaves.
- Core::group_members(convo_id) mirrors group_add_member's dispatch: a
cached group yields its members, a direct conversation is an
UnsupportedFunction, otherwise the group is loaded.
- ChatClient::group_members returns Vec<GroupMember>, resolving each
member's account claim through the directory. A member whose account
claim is unconfirmable is listed by device with account None rather
than dropped: it is cryptographically in the group, only the account
claim is unproven. The credential parsing decode_sender did is
factored into parse_credential and shared by both, leaving
decode_sender's stricter drop semantics for message senders unchanged.
- Because resolve_device_ids fans an account out to every endorsed
device, an account whose devices all join surfaced once per device;
group_members dedups by account, keeping the first-seen device as the
account's representative. Members with no confirmed account stay
individual, keyed by their unique device key.
- Unit tests cover the tolerant-vs-drop split and the per-account dedup;
the three-member group integration test asserts the roster converges
after create and after each add, and a solo group lists only its
creator.
* feat: retry the registry on transient 5xx with backoff and jitter
The keypackage/account registry is reliable request-by-request but sheds concurrent bursts with a 5xx, so several instances registering at once each hard-failed on init. HttpRegistry's four calls now retry network errors and 5xx/429 with exponential backoff and full jitter (the jitter decorrelates concurrent publishers so their retries don't re-collide); 4xx and success return immediately. The total retry window is bounded to a few seconds.
* fix: mark InboxV2 key package last-resort so members can join multiple groups
A key package's init key is one-time-use: openmls deletes it after the first
welcome that consumes it. Each installation registers a single key package, so a
second group inviting the same member found no matching key package and rejected
the welcome with "welcome not addressed to this member", the flaky group add.
Mark the InboxV2 key package as last-resort (and advertise the extension in the
leaf capabilities, which key-package validation requires) so openmls retains the
init key, letting one key package admit an installation to any number of groups.
This reuses one init key for every join, trading per-join forward secrecy for
membership that just works. A TODO at the publish site tracks the intended
one-time key-package pool (the registry pops one per fetch, the client
replenishes) with last-resort as the exhaustion fallback (#169).
Add regression tests: a member joining two groups (core harness) and two peers
invited to several groups over the threaded client.
* fix: dedup list_conversations across the store and the in-memory cache
A DirectV1 join persists its conversation to the store and also caches it in
memory, so list_conversations saw it twice. It deduped with Vec::dedup, which
only drops consecutive repeats, over cached_convos' nondeterministic HashMap
order, so the duplicate survived whenever another cached conversation fell
between the two copies. list_conversations then intermittently returned a
conversation twice, and a consumer counting conversations (e.g. checking that a
peer joined a group while a direct chat already existed) saw a flaky count.
Dedup through a set so a conversation held in both stores is listed once
regardless of iteration order.
Add a DirectV1-then-GroupV2 regression test, which also covers key-package reuse
across conversation types.
* fix: dedup the GroupV2 add batch to avoid redundant fetches and duplicate invites
Both create_group_convo_v2 and group_add_member funnel through
GroupV2Convo::add_member, so a duplicate signer (an account that resolves
to the same signer twice, or a repeated account) cost a redundant
key-package fetch and a second Add proposal. The existing guard skipped
only self and already-committed members, which a within-batch duplicate
escapes because add_member opens a proposal the committed roster does not
yet reflect, stranding a pending_invite that can later fire a spurious
duplicate welcome.
Dedup the requested signers before fetching, and guard the add loop with a
membership set seeded from the roster and self, hoisting the per-iteration
members() call out of the loop.
* docs: correct the retry-budget and group-add doc comments
The retry-budget comment claimed the ~20s init IPC budget held even at the
worst-case sum, but that only holds on the load-shed path where each retry
returns fast; a fully unreachable registry costs up to MAX_RETRIES times
the reqwest timeout, which no retry budget can rescue. State both.
Reword add_group_members to name the proposal, commit, and welcome flow
rather than the unexplained "once the add commits".
* chore: allow clippy::question_mark in LocalBroadcaster::poll (Rust 1.97 FP)
Stable rolled to 1.97, whose clippy question_mark flags poll()'s match on
`self.shared.borrow().read(next)`. Its suggested `read(next)?` would drop the
RefCell Ref guard and dangle the returned reference, so the lint is a false
positive here. CI tracks floating stable (`rustup update stable`), so this is
pre-existing code newly flagged; suppress it to keep the branch green.
2026-07-09 20:02:04 +02:00
|
|
|
/// Each member's MLS leaf-credential content (hex-encoded); errors if
|
|
|
|
|
/// `convo_id` names a direct (non-group) conversation.
|
|
|
|
|
pub fn group_members(&mut self, convo_id: &str) -> Result<Vec<Vec<u8>>, ChatError> {
|
|
|
|
|
if self.cached_convos.contains_key(convo_id) {
|
|
|
|
|
let convo = self
|
|
|
|
|
.cached_convos
|
|
|
|
|
.get(convo_id)
|
|
|
|
|
.ok_or_else(|| ChatError::NoConvo(convo_id.to_string()))?;
|
|
|
|
|
|
|
|
|
|
match convo {
|
|
|
|
|
ConvoTypeOwned::Group(group_convo) => group_convo.members(),
|
|
|
|
|
ConvoTypeOwned::Direct(convo) => Err(ChatError::UnsupportedFunction(
|
|
|
|
|
convo.id().into(),
|
|
|
|
|
"List Members".into(),
|
|
|
|
|
)),
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
let convo = self.load_group_convo(convo_id)?;
|
|
|
|
|
convo.members()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
refactor(core): replace Rc-based Context with a synchronous, Send-able Core (#123)
Make the conversations core Send so the threaded client can own it behind an
Arc<Mutex<Core>>: a background worker polls the transport and handles inbound
payloads while the application thread issues outbound calls (send, create
conversation). Sharing the core across those two threads means moving it into
the spawned worker, which is only legal if it is Send. Access stays serialized
by the client's Mutex (one thread at a time), so the core needs Send but not
Sync and carries no lock of its own. See
docs/adr/0001-client-event-system.md for the background-poller design.
The Rc<RefCell> service-sharing is what made the core !Send. Context is de-Rc'd
and renamed to Core, owning its services outright and driving the inbox and
conversation primitives with plain &mut self.
- Services (identity, delivery, store, registry, MLS context, causal history)
are bundled into a ServiceContext<S> behind an ExternalServices trait, with
S = (DS, RS, CS). Constructors live on the (DS, RS, CS) form because S cannot
be inferred backwards through S::DS.
- Inbox, InboxV2, PrivateV1Convo, and GroupV1Convo become non-generic and
receive the ServiceContext bundle as a &mut/& parameter; no Rc or
RefCell-as-shared-state remains, so Core is Send whenever its injected
services are.
- Dispatch branches on ConversationKind in one place: Core rebuilds the target
as a Convo<S>/GroupConvo<S> trait object bound to the service bundle, so
conversations never escape the orchestrator.
- CausalHistoryStore drops its Rc, keeping a plain RefCell.
2026-06-08 21:55:33 +02:00
|
|
|
pub fn list_conversations(&self) -> Result<Vec<ConversationId>, ChatError> {
|
2026-06-15 13:15:18 -07:00
|
|
|
// Check Legacy load_convo store
|
refactor(core): replace Rc-based Context with a synchronous, Send-able Core (#123)
Make the conversations core Send so the threaded client can own it behind an
Arc<Mutex<Core>>: a background worker polls the transport and handles inbound
payloads while the application thread issues outbound calls (send, create
conversation). Sharing the core across those two threads means moving it into
the spawned worker, which is only legal if it is Send. Access stays serialized
by the client's Mutex (one thread at a time), so the core needs Send but not
Sync and carries no lock of its own. See
docs/adr/0001-client-event-system.md for the background-poller design.
The Rc<RefCell> service-sharing is what made the core !Send. Context is de-Rc'd
and renamed to Core, owning its services outright and driving the inbox and
conversation primitives with plain &mut self.
- Services (identity, delivery, store, registry, MLS context, causal history)
are bundled into a ServiceContext<S> behind an ExternalServices trait, with
S = (DS, RS, CS). Constructors live on the (DS, RS, CS) form because S cannot
be inferred backwards through S::DS.
- Inbox, InboxV2, PrivateV1Convo, and GroupV1Convo become non-generic and
receive the ServiceContext bundle as a &mut/& parameter; no Rc or
RefCell-as-shared-state remains, so Core is Send whenever its injected
services are.
- Dispatch branches on ConversationKind in one place: Core rebuilds the target
as a Convo<S>/GroupConvo<S> trait object bound to the service bundle, so
conversations never escape the orchestrator.
- CausalHistoryStore drops its Rc, keeping a plain RefCell.
2026-06-08 21:55:33 +02:00
|
|
|
let records = self.services.store.load_conversations()?;
|
2026-06-15 13:15:18 -07:00
|
|
|
let mut convos: Vec<ConversationId> =
|
|
|
|
|
records.into_iter().map(|r| r.local_convo_id).collect();
|
|
|
|
|
|
|
|
|
|
// Add cached mls convos
|
|
|
|
|
for convo in self.cached_convos.keys() {
|
|
|
|
|
convos.push(convo.to_string());
|
|
|
|
|
}
|
|
|
|
|
|
feat: GroupV2 through the threaded client, group roster, registry retry (#167)
* feat: expose GroupV2 through the threaded client
GroupV2 (de-mls) conversations were reachable only from Core, and every
conversation ran the hardcoded millisecond timer profile in group_v2.rs
(20-150 ms freeze/consensus windows), which cannot survive real network
latency. Make them reachable through ChatClient (as DirectV1 already is), with
timing that holds up over a real network.
- ChatClient::create_group_conversation(accounts) and
add_group_members(convo_id, accounts): resolve each account address to
its endorsed signer ids through the client-held directory, the same
resolution create_direct_conversation uses, and drive
Core::create_group_convo / group_add_member.
- GroupV2 timing/policy is injectable: ServiceContext carries a
de_mls::ConversationConfig (re-exported as GroupV2Config) defaulting
to the de-mls library defaults; Core::set_group_v2_config and the
builder's group_v2_config setter override it. The creator's phase
durations reach joiners inside the welcome's ConversationSync, so the
group runs the creator's phase timing.
- GroupV2Convo::add_member validates every member's key package before
proposing any add, skips members de-mls would silently not propose
(self, already in the group) instead of stranding a pending invite,
and flushes opened proposals even on a mid-batch failure, so a failed
batch cannot invite members behind the caller's back.
- The millisecond test profile moves into the test harnesses
(integration_tests_core's TestHarness, crates/client/tests/group_v2.rs).
- New client-level tests: three accounts on the in-process transport
create a group, a non-creator adds the third member, and messages fan
out with directory-verified senders; a batch containing a member with
no key package fails without inviting anyone.
* fix: class inbound DirectV1 joins as Private, not Group
The joiner of a DirectV1 (pairwise) conversation received it classed as
Group, because dispatch_to_inbox2 hardcoded ConversationClass::Group for
every InboxV2 join. DirectV1 welcomes (InviteType::GroupV1) and GroupV2
welcomes (InviteType::GroupV2) both arrive over InboxV2, so a plain 1:1
invite surfaced to the display layer as a group. ConversationClass is
documented as stable across protocol versions of the same conversation
shape, and DirectV1 is the pairwise shape, so its joiner must see Private.
- InboxV2::handle_frame returns the class alongside the convo:
InviteType::GroupV1 (the DirectV1 welcome carrier) yields Private,
InviteType::GroupV2 yields Group.
- dispatch_to_inbox2 propagates that class instead of hardcoding Group.
- direct_v1_by_account_address asserts the joiner sees Private.
* feat: expose a group's roster, deduped to one entry per account
The display layer needs a group's membership, but nothing exposed it:
de-mls holds the authoritative roster (MLS group state) with no public
accessor, and members added by other members stay invisible until they
send a message. Rebuilding the roster from observed messages would fork
state the crypto layer owns and be wrong exactly when a group grows.
- GroupConvo::members() returns each member's hex-encoded MLS
leaf-credential content, self included. GroupV2Convo delegates to
de-mls and guarantees self-inclusion; GroupV1Convo reads its openmls
leaves.
- Core::group_members(convo_id) mirrors group_add_member's dispatch: a
cached group yields its members, a direct conversation is an
UnsupportedFunction, otherwise the group is loaded.
- ChatClient::group_members returns Vec<GroupMember>, resolving each
member's account claim through the directory. A member whose account
claim is unconfirmable is listed by device with account None rather
than dropped: it is cryptographically in the group, only the account
claim is unproven. The credential parsing decode_sender did is
factored into parse_credential and shared by both, leaving
decode_sender's stricter drop semantics for message senders unchanged.
- Because resolve_device_ids fans an account out to every endorsed
device, an account whose devices all join surfaced once per device;
group_members dedups by account, keeping the first-seen device as the
account's representative. Members with no confirmed account stay
individual, keyed by their unique device key.
- Unit tests cover the tolerant-vs-drop split and the per-account dedup;
the three-member group integration test asserts the roster converges
after create and after each add, and a solo group lists only its
creator.
* feat: retry the registry on transient 5xx with backoff and jitter
The keypackage/account registry is reliable request-by-request but sheds concurrent bursts with a 5xx, so several instances registering at once each hard-failed on init. HttpRegistry's four calls now retry network errors and 5xx/429 with exponential backoff and full jitter (the jitter decorrelates concurrent publishers so their retries don't re-collide); 4xx and success return immediately. The total retry window is bounded to a few seconds.
* fix: mark InboxV2 key package last-resort so members can join multiple groups
A key package's init key is one-time-use: openmls deletes it after the first
welcome that consumes it. Each installation registers a single key package, so a
second group inviting the same member found no matching key package and rejected
the welcome with "welcome not addressed to this member", the flaky group add.
Mark the InboxV2 key package as last-resort (and advertise the extension in the
leaf capabilities, which key-package validation requires) so openmls retains the
init key, letting one key package admit an installation to any number of groups.
This reuses one init key for every join, trading per-join forward secrecy for
membership that just works. A TODO at the publish site tracks the intended
one-time key-package pool (the registry pops one per fetch, the client
replenishes) with last-resort as the exhaustion fallback (#169).
Add regression tests: a member joining two groups (core harness) and two peers
invited to several groups over the threaded client.
* fix: dedup list_conversations across the store and the in-memory cache
A DirectV1 join persists its conversation to the store and also caches it in
memory, so list_conversations saw it twice. It deduped with Vec::dedup, which
only drops consecutive repeats, over cached_convos' nondeterministic HashMap
order, so the duplicate survived whenever another cached conversation fell
between the two copies. list_conversations then intermittently returned a
conversation twice, and a consumer counting conversations (e.g. checking that a
peer joined a group while a direct chat already existed) saw a flaky count.
Dedup through a set so a conversation held in both stores is listed once
regardless of iteration order.
Add a DirectV1-then-GroupV2 regression test, which also covers key-package reuse
across conversation types.
* fix: dedup the GroupV2 add batch to avoid redundant fetches and duplicate invites
Both create_group_convo_v2 and group_add_member funnel through
GroupV2Convo::add_member, so a duplicate signer (an account that resolves
to the same signer twice, or a repeated account) cost a redundant
key-package fetch and a second Add proposal. The existing guard skipped
only self and already-committed members, which a within-batch duplicate
escapes because add_member opens a proposal the committed roster does not
yet reflect, stranding a pending_invite that can later fire a spurious
duplicate welcome.
Dedup the requested signers before fetching, and guard the add loop with a
membership set seeded from the roster and self, hoisting the per-iteration
members() call out of the loop.
* docs: correct the retry-budget and group-add doc comments
The retry-budget comment claimed the ~20s init IPC budget held even at the
worst-case sum, but that only holds on the load-shed path where each retry
returns fast; a fully unreachable registry costs up to MAX_RETRIES times
the reqwest timeout, which no retry budget can rescue. State both.
Reword add_group_members to name the proposal, commit, and welcome flow
rather than the unexplained "once the add commits".
* chore: allow clippy::question_mark in LocalBroadcaster::poll (Rust 1.97 FP)
Stable rolled to 1.97, whose clippy question_mark flags poll()'s match on
`self.shared.borrow().read(next)`. Its suggested `read(next)?` would drop the
RefCell Ref guard and dangle the returned reference, so the lint is a false
positive here. CI tracks floating stable (`rustup update stable`), so this is
pre-existing code newly flagged; suppress it to keep the branch green.
2026-07-09 20:02:04 +02:00
|
|
|
// A conversation can live in both the store and the in-memory cache (a
|
|
|
|
|
// DirectV1 join persists to the store and is also cached), so drop
|
|
|
|
|
// duplicates across the two. `Vec::dedup` only removes *consecutive*
|
|
|
|
|
// repeats and `cached_convos` iterates in nondeterministic HashMap
|
|
|
|
|
// order, so dedup through a set instead.
|
|
|
|
|
let mut seen = std::collections::HashSet::new();
|
|
|
|
|
convos.retain(|c| seen.insert(c.clone()));
|
2026-06-15 13:15:18 -07:00
|
|
|
Ok(convos)
|
refactor(core): replace Rc-based Context with a synchronous, Send-able Core (#123)
Make the conversations core Send so the threaded client can own it behind an
Arc<Mutex<Core>>: a background worker polls the transport and handles inbound
payloads while the application thread issues outbound calls (send, create
conversation). Sharing the core across those two threads means moving it into
the spawned worker, which is only legal if it is Send. Access stays serialized
by the client's Mutex (one thread at a time), so the core needs Send but not
Sync and carries no lock of its own. See
docs/adr/0001-client-event-system.md for the background-poller design.
The Rc<RefCell> service-sharing is what made the core !Send. Context is de-Rc'd
and renamed to Core, owning its services outright and driving the inbox and
conversation primitives with plain &mut self.
- Services (identity, delivery, store, registry, MLS context, causal history)
are bundled into a ServiceContext<S> behind an ExternalServices trait, with
S = (DS, RS, CS). Constructors live on the (DS, RS, CS) form because S cannot
be inferred backwards through S::DS.
- Inbox, InboxV2, PrivateV1Convo, and GroupV1Convo become non-generic and
receive the ServiceContext bundle as a &mut/& parameter; no Rc or
RefCell-as-shared-state remains, so Core is Send whenever its injected
services are.
- Dispatch branches on ConversationKind in one place: Core rebuilds the target
as a Convo<S>/GroupConvo<S> trait object bound to the service bundle, so
conversations never escape the orchestrator.
- CausalHistoryStore drops its Rc, keeping a plain RefCell.
2026-06-08 21:55:33 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn take_missing_messages(&self) -> Vec<MissingMessage> {
|
|
|
|
|
self.services.causal.take_missing()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Encrypt and publish `content` to an existing conversation.
|
|
|
|
|
pub fn send_content(&mut self, convo_id: &str, content: &[u8]) -> Result<(), ChatError> {
|
2026-06-15 13:15:18 -07:00
|
|
|
if self.cached_convos.contains_key(convo_id) {
|
|
|
|
|
let convo = self
|
|
|
|
|
.cached_convos
|
|
|
|
|
.get_mut(convo_id)
|
|
|
|
|
.ok_or_else(|| ChatError::NoConvo(convo_id.to_string()))?;
|
|
|
|
|
convo.send_content(&mut self.services, content)
|
|
|
|
|
} else {
|
|
|
|
|
let mut convo = self.load_convo(convo_id)?;
|
|
|
|
|
convo.send_content(&mut self.services, content)
|
|
|
|
|
}
|
refactor(core): replace Rc-based Context with a synchronous, Send-able Core (#123)
Make the conversations core Send so the threaded client can own it behind an
Arc<Mutex<Core>>: a background worker polls the transport and handles inbound
payloads while the application thread issues outbound calls (send, create
conversation). Sharing the core across those two threads means moving it into
the spawned worker, which is only legal if it is Send. Access stays serialized
by the client's Mutex (one thread at a time), so the core needs Send but not
Sync and carries no lock of its own. See
docs/adr/0001-client-event-system.md for the background-poller design.
The Rc<RefCell> service-sharing is what made the core !Send. Context is de-Rc'd
and renamed to Core, owning its services outright and driving the inbox and
conversation primitives with plain &mut self.
- Services (identity, delivery, store, registry, MLS context, causal history)
are bundled into a ServiceContext<S> behind an ExternalServices trait, with
S = (DS, RS, CS). Constructors live on the (DS, RS, CS) form because S cannot
be inferred backwards through S::DS.
- Inbox, InboxV2, PrivateV1Convo, and GroupV1Convo become non-generic and
receive the ServiceContext bundle as a &mut/& parameter; no Rc or
RefCell-as-shared-state remains, so Core is Send whenever its injected
services are.
- Dispatch branches on ConversationKind in one place: Core rebuilds the target
as a Convo<S>/GroupConvo<S> trait object bound to the service bundle, so
conversations never escape the orchestrator.
- CausalHistoryStore drops its Rc, keeping a plain RefCell.
2026-06-08 21:55:33 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Decode bytes and send to protocol for processing.
|
2026-06-15 13:15:18 -07:00
|
|
|
#[instrument(name = "core.handle_frame", skip_all, fields(user_id = %self.services.mls_identity.display_name()))]
|
refactor(core): replace Rc-based Context with a synchronous, Send-able Core (#123)
Make the conversations core Send so the threaded client can own it behind an
Arc<Mutex<Core>>: a background worker polls the transport and handles inbound
payloads while the application thread issues outbound calls (send, create
conversation). Sharing the core across those two threads means moving it into
the spawned worker, which is only legal if it is Send. Access stays serialized
by the client's Mutex (one thread at a time), so the core needs Send but not
Sync and carries no lock of its own. See
docs/adr/0001-client-event-system.md for the background-poller design.
The Rc<RefCell> service-sharing is what made the core !Send. Context is de-Rc'd
and renamed to Core, owning its services outright and driving the inbox and
conversation primitives with plain &mut self.
- Services (identity, delivery, store, registry, MLS context, causal history)
are bundled into a ServiceContext<S> behind an ExternalServices trait, with
S = (DS, RS, CS). Constructors live on the (DS, RS, CS) form because S cannot
be inferred backwards through S::DS.
- Inbox, InboxV2, PrivateV1Convo, and GroupV1Convo become non-generic and
receive the ServiceContext bundle as a &mut/& parameter; no Rc or
RefCell-as-shared-state remains, so Core is Send whenever its injected
services are.
- Dispatch branches on ConversationKind in one place: Core rebuilds the target
as a Convo<S>/GroupConvo<S> trait object bound to the service bundle, so
conversations never escape the orchestrator.
- CausalHistoryStore drops its Rc, keeping a plain RefCell.
2026-06-08 21:55:33 +02:00
|
|
|
pub fn handle_payload(&mut self, payload: &[u8]) -> Result<PayloadOutcome, ChatError> {
|
|
|
|
|
let env = EnvelopeV1::decode(payload)?;
|
|
|
|
|
|
|
|
|
|
// TODO: Impl Conversation hinting
|
|
|
|
|
let convo_id = env.conversation_hint;
|
|
|
|
|
|
|
|
|
|
match convo_id {
|
|
|
|
|
c if c == self.inbox.id() => self.dispatch_to_inbox(&env.payload).map(Into::into),
|
2026-06-15 13:15:18 -07:00
|
|
|
c if c == self.pq_inbox.id() => self.dispatch_to_inbox2(&env.payload),
|
|
|
|
|
c if self.cached_convos.contains_key(&c) => {
|
|
|
|
|
self.dispatch_to_convo(&c, &env.payload).map(Into::into)
|
|
|
|
|
}
|
refactor(core): replace Rc-based Context with a synchronous, Send-able Core (#123)
Make the conversations core Send so the threaded client can own it behind an
Arc<Mutex<Core>>: a background worker polls the transport and handles inbound
payloads while the application thread issues outbound calls (send, create
conversation). Sharing the core across those two threads means moving it into
the spawned worker, which is only legal if it is Send. Access stays serialized
by the client's Mutex (one thread at a time), so the core needs Send but not
Sync and carries no lock of its own. See
docs/adr/0001-client-event-system.md for the background-poller design.
The Rc<RefCell> service-sharing is what made the core !Send. Context is de-Rc'd
and renamed to Core, owning its services outright and driving the inbox and
conversation primitives with plain &mut self.
- Services (identity, delivery, store, registry, MLS context, causal history)
are bundled into a ServiceContext<S> behind an ExternalServices trait, with
S = (DS, RS, CS). Constructors live on the (DS, RS, CS) form because S cannot
be inferred backwards through S::DS.
- Inbox, InboxV2, PrivateV1Convo, and GroupV1Convo become non-generic and
receive the ServiceContext bundle as a &mut/& parameter; no Rc or
RefCell-as-shared-state remains, so Core is Send whenever its injected
services are.
- Dispatch branches on ConversationKind in one place: Core rebuilds the target
as a Convo<S>/GroupConvo<S> trait object bound to the service bundle, so
conversations never escape the orchestrator.
- CausalHistoryStore drops its Rc, keeping a plain RefCell.
2026-06-08 21:55:33 +02:00
|
|
|
c if self.services.store.has_conversation(&c)? => {
|
|
|
|
|
self.dispatch_to_convo(&c, &env.payload).map(Into::into)
|
|
|
|
|
}
|
|
|
|
|
_ => Ok(PayloadOutcome::Empty),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Dispatch encrypted payload to Inbox. The Inbox persists the newly
|
|
|
|
|
// created conversation and consumes the ephemeral key internally.
|
|
|
|
|
fn dispatch_to_inbox(&mut self, enc_payload_bytes: &[u8]) -> Result<InboxOutcome, ChatError> {
|
|
|
|
|
// EncryptedPayloads are not used by GroupConvos at this time, else this can be performed in `handle_payload`
|
|
|
|
|
// TODO: (P1) reconcile envelope parsing between Covno and GroupConvo
|
|
|
|
|
let enc_payload = EncryptedPayload::decode(enc_payload_bytes)?;
|
|
|
|
|
let public_key_hex = Inbox::extract_ephemeral_key_hex(&enc_payload)?;
|
|
|
|
|
self.inbox
|
|
|
|
|
.handle_frame(&mut self.services, enc_payload, &public_key_hex)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Dispatch encrypted payload to the post-quantum inbox.
|
2026-06-15 13:15:18 -07:00
|
|
|
fn dispatch_to_inbox2(&mut self, payload: &[u8]) -> Result<PayloadOutcome, ChatError> {
|
feat: GroupV2 through the threaded client, group roster, registry retry (#167)
* feat: expose GroupV2 through the threaded client
GroupV2 (de-mls) conversations were reachable only from Core, and every
conversation ran the hardcoded millisecond timer profile in group_v2.rs
(20-150 ms freeze/consensus windows), which cannot survive real network
latency. Make them reachable through ChatClient (as DirectV1 already is), with
timing that holds up over a real network.
- ChatClient::create_group_conversation(accounts) and
add_group_members(convo_id, accounts): resolve each account address to
its endorsed signer ids through the client-held directory, the same
resolution create_direct_conversation uses, and drive
Core::create_group_convo / group_add_member.
- GroupV2 timing/policy is injectable: ServiceContext carries a
de_mls::ConversationConfig (re-exported as GroupV2Config) defaulting
to the de-mls library defaults; Core::set_group_v2_config and the
builder's group_v2_config setter override it. The creator's phase
durations reach joiners inside the welcome's ConversationSync, so the
group runs the creator's phase timing.
- GroupV2Convo::add_member validates every member's key package before
proposing any add, skips members de-mls would silently not propose
(self, already in the group) instead of stranding a pending invite,
and flushes opened proposals even on a mid-batch failure, so a failed
batch cannot invite members behind the caller's back.
- The millisecond test profile moves into the test harnesses
(integration_tests_core's TestHarness, crates/client/tests/group_v2.rs).
- New client-level tests: three accounts on the in-process transport
create a group, a non-creator adds the third member, and messages fan
out with directory-verified senders; a batch containing a member with
no key package fails without inviting anyone.
* fix: class inbound DirectV1 joins as Private, not Group
The joiner of a DirectV1 (pairwise) conversation received it classed as
Group, because dispatch_to_inbox2 hardcoded ConversationClass::Group for
every InboxV2 join. DirectV1 welcomes (InviteType::GroupV1) and GroupV2
welcomes (InviteType::GroupV2) both arrive over InboxV2, so a plain 1:1
invite surfaced to the display layer as a group. ConversationClass is
documented as stable across protocol versions of the same conversation
shape, and DirectV1 is the pairwise shape, so its joiner must see Private.
- InboxV2::handle_frame returns the class alongside the convo:
InviteType::GroupV1 (the DirectV1 welcome carrier) yields Private,
InviteType::GroupV2 yields Group.
- dispatch_to_inbox2 propagates that class instead of hardcoding Group.
- direct_v1_by_account_address asserts the joiner sees Private.
* feat: expose a group's roster, deduped to one entry per account
The display layer needs a group's membership, but nothing exposed it:
de-mls holds the authoritative roster (MLS group state) with no public
accessor, and members added by other members stay invisible until they
send a message. Rebuilding the roster from observed messages would fork
state the crypto layer owns and be wrong exactly when a group grows.
- GroupConvo::members() returns each member's hex-encoded MLS
leaf-credential content, self included. GroupV2Convo delegates to
de-mls and guarantees self-inclusion; GroupV1Convo reads its openmls
leaves.
- Core::group_members(convo_id) mirrors group_add_member's dispatch: a
cached group yields its members, a direct conversation is an
UnsupportedFunction, otherwise the group is loaded.
- ChatClient::group_members returns Vec<GroupMember>, resolving each
member's account claim through the directory. A member whose account
claim is unconfirmable is listed by device with account None rather
than dropped: it is cryptographically in the group, only the account
claim is unproven. The credential parsing decode_sender did is
factored into parse_credential and shared by both, leaving
decode_sender's stricter drop semantics for message senders unchanged.
- Because resolve_device_ids fans an account out to every endorsed
device, an account whose devices all join surfaced once per device;
group_members dedups by account, keeping the first-seen device as the
account's representative. Members with no confirmed account stay
individual, keyed by their unique device key.
- Unit tests cover the tolerant-vs-drop split and the per-account dedup;
the three-member group integration test asserts the roster converges
after create and after each add, and a solo group lists only its
creator.
* feat: retry the registry on transient 5xx with backoff and jitter
The keypackage/account registry is reliable request-by-request but sheds concurrent bursts with a 5xx, so several instances registering at once each hard-failed on init. HttpRegistry's four calls now retry network errors and 5xx/429 with exponential backoff and full jitter (the jitter decorrelates concurrent publishers so their retries don't re-collide); 4xx and success return immediately. The total retry window is bounded to a few seconds.
* fix: mark InboxV2 key package last-resort so members can join multiple groups
A key package's init key is one-time-use: openmls deletes it after the first
welcome that consumes it. Each installation registers a single key package, so a
second group inviting the same member found no matching key package and rejected
the welcome with "welcome not addressed to this member", the flaky group add.
Mark the InboxV2 key package as last-resort (and advertise the extension in the
leaf capabilities, which key-package validation requires) so openmls retains the
init key, letting one key package admit an installation to any number of groups.
This reuses one init key for every join, trading per-join forward secrecy for
membership that just works. A TODO at the publish site tracks the intended
one-time key-package pool (the registry pops one per fetch, the client
replenishes) with last-resort as the exhaustion fallback (#169).
Add regression tests: a member joining two groups (core harness) and two peers
invited to several groups over the threaded client.
* fix: dedup list_conversations across the store and the in-memory cache
A DirectV1 join persists its conversation to the store and also caches it in
memory, so list_conversations saw it twice. It deduped with Vec::dedup, which
only drops consecutive repeats, over cached_convos' nondeterministic HashMap
order, so the duplicate survived whenever another cached conversation fell
between the two copies. list_conversations then intermittently returned a
conversation twice, and a consumer counting conversations (e.g. checking that a
peer joined a group while a direct chat already existed) saw a flaky count.
Dedup through a set so a conversation held in both stores is listed once
regardless of iteration order.
Add a DirectV1-then-GroupV2 regression test, which also covers key-package reuse
across conversation types.
* fix: dedup the GroupV2 add batch to avoid redundant fetches and duplicate invites
Both create_group_convo_v2 and group_add_member funnel through
GroupV2Convo::add_member, so a duplicate signer (an account that resolves
to the same signer twice, or a repeated account) cost a redundant
key-package fetch and a second Add proposal. The existing guard skipped
only self and already-committed members, which a within-batch duplicate
escapes because add_member opens a proposal the committed roster does not
yet reflect, stranding a pending_invite that can later fire a spurious
duplicate welcome.
Dedup the requested signers before fetching, and guard the add loop with a
membership set seeded from the roster and self, hoisting the per-iteration
members() call out of the loop.
* docs: correct the retry-budget and group-add doc comments
The retry-budget comment claimed the ~20s init IPC budget held even at the
worst-case sum, but that only holds on the load-shed path where each retry
returns fast; a fully unreachable registry costs up to MAX_RETRIES times
the reqwest timeout, which no retry budget can rescue. State both.
Reword add_group_members to name the proposal, commit, and welcome flow
rather than the unexplained "once the add commits".
* chore: allow clippy::question_mark in LocalBroadcaster::poll (Rust 1.97 FP)
Stable rolled to 1.97, whose clippy question_mark flags poll()'s match on
`self.shared.borrow().read(next)`. Its suggested `read(next)?` would drop the
RefCell Ref guard and dangle the returned reference, so the lint is a false
positive here. CI tracks floating stable (`rustup update stable`), so this is
pre-existing code newly flagged; suppress it to keep the branch green.
2026-07-09 20:02:04 +02:00
|
|
|
if let Some((convo, class)) = self.pq_inbox.handle_frame(&mut self.services, payload)? {
|
2026-06-15 13:15:18 -07:00
|
|
|
let convo_id = convo.id().to_string();
|
|
|
|
|
// Cache convos created by InboxV2
|
|
|
|
|
self.register_convo(ConvoTypeOwned::Group(convo))?;
|
|
|
|
|
|
|
|
|
|
Ok(PayloadOutcome::Inbox(InboxOutcome {
|
feat: GroupV2 through the threaded client, group roster, registry retry (#167)
* feat: expose GroupV2 through the threaded client
GroupV2 (de-mls) conversations were reachable only from Core, and every
conversation ran the hardcoded millisecond timer profile in group_v2.rs
(20-150 ms freeze/consensus windows), which cannot survive real network
latency. Make them reachable through ChatClient (as DirectV1 already is), with
timing that holds up over a real network.
- ChatClient::create_group_conversation(accounts) and
add_group_members(convo_id, accounts): resolve each account address to
its endorsed signer ids through the client-held directory, the same
resolution create_direct_conversation uses, and drive
Core::create_group_convo / group_add_member.
- GroupV2 timing/policy is injectable: ServiceContext carries a
de_mls::ConversationConfig (re-exported as GroupV2Config) defaulting
to the de-mls library defaults; Core::set_group_v2_config and the
builder's group_v2_config setter override it. The creator's phase
durations reach joiners inside the welcome's ConversationSync, so the
group runs the creator's phase timing.
- GroupV2Convo::add_member validates every member's key package before
proposing any add, skips members de-mls would silently not propose
(self, already in the group) instead of stranding a pending invite,
and flushes opened proposals even on a mid-batch failure, so a failed
batch cannot invite members behind the caller's back.
- The millisecond test profile moves into the test harnesses
(integration_tests_core's TestHarness, crates/client/tests/group_v2.rs).
- New client-level tests: three accounts on the in-process transport
create a group, a non-creator adds the third member, and messages fan
out with directory-verified senders; a batch containing a member with
no key package fails without inviting anyone.
* fix: class inbound DirectV1 joins as Private, not Group
The joiner of a DirectV1 (pairwise) conversation received it classed as
Group, because dispatch_to_inbox2 hardcoded ConversationClass::Group for
every InboxV2 join. DirectV1 welcomes (InviteType::GroupV1) and GroupV2
welcomes (InviteType::GroupV2) both arrive over InboxV2, so a plain 1:1
invite surfaced to the display layer as a group. ConversationClass is
documented as stable across protocol versions of the same conversation
shape, and DirectV1 is the pairwise shape, so its joiner must see Private.
- InboxV2::handle_frame returns the class alongside the convo:
InviteType::GroupV1 (the DirectV1 welcome carrier) yields Private,
InviteType::GroupV2 yields Group.
- dispatch_to_inbox2 propagates that class instead of hardcoding Group.
- direct_v1_by_account_address asserts the joiner sees Private.
* feat: expose a group's roster, deduped to one entry per account
The display layer needs a group's membership, but nothing exposed it:
de-mls holds the authoritative roster (MLS group state) with no public
accessor, and members added by other members stay invisible until they
send a message. Rebuilding the roster from observed messages would fork
state the crypto layer owns and be wrong exactly when a group grows.
- GroupConvo::members() returns each member's hex-encoded MLS
leaf-credential content, self included. GroupV2Convo delegates to
de-mls and guarantees self-inclusion; GroupV1Convo reads its openmls
leaves.
- Core::group_members(convo_id) mirrors group_add_member's dispatch: a
cached group yields its members, a direct conversation is an
UnsupportedFunction, otherwise the group is loaded.
- ChatClient::group_members returns Vec<GroupMember>, resolving each
member's account claim through the directory. A member whose account
claim is unconfirmable is listed by device with account None rather
than dropped: it is cryptographically in the group, only the account
claim is unproven. The credential parsing decode_sender did is
factored into parse_credential and shared by both, leaving
decode_sender's stricter drop semantics for message senders unchanged.
- Because resolve_device_ids fans an account out to every endorsed
device, an account whose devices all join surfaced once per device;
group_members dedups by account, keeping the first-seen device as the
account's representative. Members with no confirmed account stay
individual, keyed by their unique device key.
- Unit tests cover the tolerant-vs-drop split and the per-account dedup;
the three-member group integration test asserts the roster converges
after create and after each add, and a solo group lists only its
creator.
* feat: retry the registry on transient 5xx with backoff and jitter
The keypackage/account registry is reliable request-by-request but sheds concurrent bursts with a 5xx, so several instances registering at once each hard-failed on init. HttpRegistry's four calls now retry network errors and 5xx/429 with exponential backoff and full jitter (the jitter decorrelates concurrent publishers so their retries don't re-collide); 4xx and success return immediately. The total retry window is bounded to a few seconds.
* fix: mark InboxV2 key package last-resort so members can join multiple groups
A key package's init key is one-time-use: openmls deletes it after the first
welcome that consumes it. Each installation registers a single key package, so a
second group inviting the same member found no matching key package and rejected
the welcome with "welcome not addressed to this member", the flaky group add.
Mark the InboxV2 key package as last-resort (and advertise the extension in the
leaf capabilities, which key-package validation requires) so openmls retains the
init key, letting one key package admit an installation to any number of groups.
This reuses one init key for every join, trading per-join forward secrecy for
membership that just works. A TODO at the publish site tracks the intended
one-time key-package pool (the registry pops one per fetch, the client
replenishes) with last-resort as the exhaustion fallback (#169).
Add regression tests: a member joining two groups (core harness) and two peers
invited to several groups over the threaded client.
* fix: dedup list_conversations across the store and the in-memory cache
A DirectV1 join persists its conversation to the store and also caches it in
memory, so list_conversations saw it twice. It deduped with Vec::dedup, which
only drops consecutive repeats, over cached_convos' nondeterministic HashMap
order, so the duplicate survived whenever another cached conversation fell
between the two copies. list_conversations then intermittently returned a
conversation twice, and a consumer counting conversations (e.g. checking that a
peer joined a group while a direct chat already existed) saw a flaky count.
Dedup through a set so a conversation held in both stores is listed once
regardless of iteration order.
Add a DirectV1-then-GroupV2 regression test, which also covers key-package reuse
across conversation types.
* fix: dedup the GroupV2 add batch to avoid redundant fetches and duplicate invites
Both create_group_convo_v2 and group_add_member funnel through
GroupV2Convo::add_member, so a duplicate signer (an account that resolves
to the same signer twice, or a repeated account) cost a redundant
key-package fetch and a second Add proposal. The existing guard skipped
only self and already-committed members, which a within-batch duplicate
escapes because add_member opens a proposal the committed roster does not
yet reflect, stranding a pending_invite that can later fire a spurious
duplicate welcome.
Dedup the requested signers before fetching, and guard the add loop with a
membership set seeded from the roster and self, hoisting the per-iteration
members() call out of the loop.
* docs: correct the retry-budget and group-add doc comments
The retry-budget comment claimed the ~20s init IPC budget held even at the
worst-case sum, but that only holds on the load-shed path where each retry
returns fast; a fully unreachable registry costs up to MAX_RETRIES times
the reqwest timeout, which no retry budget can rescue. State both.
Reword add_group_members to name the proposal, commit, and welcome flow
rather than the unexplained "once the add commits".
* chore: allow clippy::question_mark in LocalBroadcaster::poll (Rust 1.97 FP)
Stable rolled to 1.97, whose clippy question_mark flags poll()'s match on
`self.shared.borrow().read(next)`. Its suggested `read(next)?` would drop the
RefCell Ref guard and dangle the returned reference, so the lint is a false
positive here. CI tracks floating stable (`rustup update stable`), so this is
pre-existing code newly flagged; suppress it to keep the branch green.
2026-07-09 20:02:04 +02:00
|
|
|
new_conversation: crate::NewConversation { convo_id, class },
|
2026-06-15 13:15:18 -07:00
|
|
|
initial: None,
|
|
|
|
|
}))
|
|
|
|
|
} else {
|
|
|
|
|
Ok(PayloadOutcome::Empty)
|
|
|
|
|
}
|
refactor(core): replace Rc-based Context with a synchronous, Send-able Core (#123)
Make the conversations core Send so the threaded client can own it behind an
Arc<Mutex<Core>>: a background worker polls the transport and handles inbound
payloads while the application thread issues outbound calls (send, create
conversation). Sharing the core across those two threads means moving it into
the spawned worker, which is only legal if it is Send. Access stays serialized
by the client's Mutex (one thread at a time), so the core needs Send but not
Sync and carries no lock of its own. See
docs/adr/0001-client-event-system.md for the background-poller design.
The Rc<RefCell> service-sharing is what made the core !Send. Context is de-Rc'd
and renamed to Core, owning its services outright and driving the inbox and
conversation primitives with plain &mut self.
- Services (identity, delivery, store, registry, MLS context, causal history)
are bundled into a ServiceContext<S> behind an ExternalServices trait, with
S = (DS, RS, CS). Constructors live on the (DS, RS, CS) form because S cannot
be inferred backwards through S::DS.
- Inbox, InboxV2, PrivateV1Convo, and GroupV1Convo become non-generic and
receive the ServiceContext bundle as a &mut/& parameter; no Rc or
RefCell-as-shared-state remains, so Core is Send whenever its injected
services are.
- Dispatch branches on ConversationKind in one place: Core rebuilds the target
as a Convo<S>/GroupConvo<S> trait object bound to the service bundle, so
conversations never escape the orchestrator.
- CausalHistoryStore drops its Rc, keeping a plain RefCell.
2026-06-08 21:55:33 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Dispatch encrypted payload to its corresponding conversation.
|
|
|
|
|
fn dispatch_to_convo(
|
|
|
|
|
&mut self,
|
|
|
|
|
convo_id: &str,
|
|
|
|
|
enc_payload_bytes: &[u8],
|
|
|
|
|
) -> Result<ConvoOutcome, ChatError> {
|
|
|
|
|
let enc_payload = EncryptedPayload::decode(enc_payload_bytes)?;
|
2026-06-15 13:15:18 -07:00
|
|
|
|
|
|
|
|
if self.cached_convos.contains_key(convo_id) {
|
|
|
|
|
let convo_type = self
|
|
|
|
|
.cached_convos
|
|
|
|
|
.get_mut(convo_id)
|
|
|
|
|
.ok_or_else(|| ChatError::NoConvo(convo_id.to_string()))?;
|
|
|
|
|
|
|
|
|
|
convo_type.handle_frame(&mut self.services, enc_payload)
|
|
|
|
|
} else {
|
|
|
|
|
let mut convo = self.load_convo(convo_id)?;
|
|
|
|
|
convo.handle_frame(&mut self.services, enc_payload)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn wakeup(&mut self, convo_id: ConversationIdRef) -> Result<(), ChatError> {
|
|
|
|
|
info!(convos = ?self.cached_convos.keys().collect::<Vec<_>>(), id = ?self.services.mls_identity.id(), "Cached Convos");
|
|
|
|
|
|
|
|
|
|
match convo_id {
|
|
|
|
|
c if c == self.pq_inbox.id() => todo!(),
|
|
|
|
|
c if self.cached_convos.contains_key(c) => self.wakeup_convo(c),
|
|
|
|
|
_ => Ok(()),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Dispatch encrypted payload to its corresponding conversation
|
|
|
|
|
fn wakeup_convo(&mut self, convo_id: ConversationIdRef) -> Result<(), ChatError> {
|
|
|
|
|
let Some(convo) = self.cached_convos.get_mut(convo_id) else {
|
|
|
|
|
return Err(ChatError::generic("No Convo Found"));
|
|
|
|
|
};
|
|
|
|
|
let convo = match convo {
|
|
|
|
|
ConvoTypeOwned::Group(c) => c.as_mut(),
|
2026-06-19 12:01:17 -07:00
|
|
|
ConvoTypeOwned::Direct(c) => c.as_mut(),
|
2026-06-15 13:15:18 -07:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
convo.wakeup(&mut self.services)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn register_convo(&mut self, convo: ConvoTypeOwned<S>) -> Result<(), ChatError> {
|
|
|
|
|
let res = self.cached_convos.insert(convo.id().to_string(), convo);
|
|
|
|
|
|
|
|
|
|
match res {
|
|
|
|
|
Some(_) => Err(ChatError::generic("Convo already exists. Cannot save")),
|
|
|
|
|
None => Ok(()),
|
|
|
|
|
}
|
refactor(core): replace Rc-based Context with a synchronous, Send-able Core (#123)
Make the conversations core Send so the threaded client can own it behind an
Arc<Mutex<Core>>: a background worker polls the transport and handles inbound
payloads while the application thread issues outbound calls (send, create
conversation). Sharing the core across those two threads means moving it into
the spawned worker, which is only legal if it is Send. Access stays serialized
by the client's Mutex (one thread at a time), so the core needs Send but not
Sync and carries no lock of its own. See
docs/adr/0001-client-event-system.md for the background-poller design.
The Rc<RefCell> service-sharing is what made the core !Send. Context is de-Rc'd
and renamed to Core, owning its services outright and driving the inbox and
conversation primitives with plain &mut self.
- Services (identity, delivery, store, registry, MLS context, causal history)
are bundled into a ServiceContext<S> behind an ExternalServices trait, with
S = (DS, RS, CS). Constructors live on the (DS, RS, CS) form because S cannot
be inferred backwards through S::DS.
- Inbox, InboxV2, PrivateV1Convo, and GroupV1Convo become non-generic and
receive the ServiceContext bundle as a &mut/& parameter; no Rc or
RefCell-as-shared-state remains, so Core is Send whenever its injected
services are.
- Dispatch branches on ConversationKind in one place: Core rebuilds the target
as a Convo<S>/GroupConvo<S> trait object bound to the service bundle, so
conversations never escape the orchestrator.
- CausalHistoryStore drops its Rc, keeping a plain RefCell.
2026-06-08 21:55:33 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Rebuilds a conversation from storage — the one site that branches on
|
|
|
|
|
/// `ConversationKind`.
|
|
|
|
|
fn load_convo(&mut self, convo_id: &str) -> Result<Box<dyn Convo<S>>, ChatError> {
|
|
|
|
|
let record = self.load_conversation_meta(convo_id)?;
|
|
|
|
|
Ok(match record.kind {
|
|
|
|
|
ConversationKind::PrivateV1 => Box::new(PrivateV1Convo::new(
|
|
|
|
|
&self.services.store,
|
|
|
|
|
record.local_convo_id,
|
|
|
|
|
record.remote_convo_id,
|
|
|
|
|
)?),
|
|
|
|
|
ConversationKind::GroupV1 => Box::new(self.load_mls_convo(&record.local_convo_id)?),
|
|
|
|
|
ConversationKind::Unknown(_) => {
|
|
|
|
|
return Err(ChatError::UnsupportedConvoType(record.kind.as_str().into()));
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Rebuilds a group conversation; errors if `convo_id` names a non-group.
|
|
|
|
|
fn load_group_convo(&mut self, convo_id: &str) -> Result<Box<dyn GroupConvo<S>>, ChatError> {
|
|
|
|
|
let record = self.load_conversation_meta(convo_id)?;
|
|
|
|
|
match record.kind {
|
|
|
|
|
ConversationKind::GroupV1 => Ok(Box::new(self.load_mls_convo(&record.local_convo_id)?)),
|
|
|
|
|
ConversationKind::PrivateV1 => {
|
|
|
|
|
Err(ChatError::NoConvo("this is not a group convo".into()))
|
|
|
|
|
}
|
|
|
|
|
ConversationKind::Unknown(_) => {
|
|
|
|
|
Err(ChatError::UnsupportedConvoType(record.kind.as_str().into()))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Rebuilds a group conversation from storage so an operation can run against it.
|
|
|
|
|
fn load_mls_convo(&mut self, convo_id: &str) -> Result<GroupV1Convo, ChatError> {
|
|
|
|
|
let group_id_bytes = hex::decode(convo_id).map_err(ChatError::generic)?;
|
|
|
|
|
let group_id = GroupId::from_slice(&group_id_bytes);
|
|
|
|
|
GroupV1Convo::load(&mut self.services, convo_id.to_string(), group_id)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn create_intro_bundle(&mut self) -> Result<Vec<u8>, ChatError> {
|
|
|
|
|
let intro = self.inbox.create_intro_bundle(&mut self.services)?;
|
|
|
|
|
Ok(intro.into())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Loads a conversation's metadata from storage.
|
|
|
|
|
fn load_conversation_meta(
|
|
|
|
|
&self,
|
|
|
|
|
convo_id: &str,
|
|
|
|
|
) -> Result<storage::ConversationMeta, ChatError> {
|
|
|
|
|
self.services
|
|
|
|
|
.store
|
|
|
|
|
.load_conversation(convo_id)?
|
|
|
|
|
.ok_or_else(|| ChatError::NoConvo(convo_id.into()))
|
|
|
|
|
}
|
2026-07-10 23:26:05 -07:00
|
|
|
|
|
|
|
|
pub fn convo_metadata(&self, convo_id: ConversationIdRef) -> Result<ConvoMetadata, ChatError> {
|
|
|
|
|
match self.cached_convos.get(convo_id) {
|
|
|
|
|
Some(ConvoTypeOwned::Group(group_convo)) => {
|
|
|
|
|
group_convo
|
|
|
|
|
.metadata()
|
|
|
|
|
.ok_or(ChatError::UnsupportedConvoType(
|
|
|
|
|
"metadata is not available for this legacy convo_type".into(),
|
|
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
Some(ConvoTypeOwned::Direct(_)) => Err(ChatError::UnsupportedFunction(
|
|
|
|
|
convo_id.into(),
|
|
|
|
|
"implementation coming".into(),
|
|
|
|
|
)),
|
|
|
|
|
None => Err(ChatError::NoConvo(convo_id.into())),
|
|
|
|
|
}
|
|
|
|
|
}
|
refactor(core): replace Rc-based Context with a synchronous, Send-able Core (#123)
Make the conversations core Send so the threaded client can own it behind an
Arc<Mutex<Core>>: a background worker polls the transport and handles inbound
payloads while the application thread issues outbound calls (send, create
conversation). Sharing the core across those two threads means moving it into
the spawned worker, which is only legal if it is Send. Access stays serialized
by the client's Mutex (one thread at a time), so the core needs Send but not
Sync and carries no lock of its own. See
docs/adr/0001-client-event-system.md for the background-poller design.
The Rc<RefCell> service-sharing is what made the core !Send. Context is de-Rc'd
and renamed to Core, owning its services outright and driving the inbox and
conversation primitives with plain &mut self.
- Services (identity, delivery, store, registry, MLS context, causal history)
are bundled into a ServiceContext<S> behind an ExternalServices trait, with
S = (DS, RS, CS). Constructors live on the (DS, RS, CS) form because S cannot
be inferred backwards through S::DS.
- Inbox, InboxV2, PrivateV1Convo, and GroupV1Convo become non-generic and
receive the ServiceContext bundle as a &mut/& parameter; no Rc or
RefCell-as-shared-state remains, so Core is Send whenever its injected
services are.
- Dispatch branches on ConversationKind in one place: Core rebuilds the target
as a Convo<S>/GroupConvo<S> trait object bound to the service bundle, so
conversations never escape the orchestrator.
- CausalHistoryStore drops its Rc, keeping a plain RefCell.
2026-06-08 21:55:33 +02:00
|
|
|
}
|
2026-06-15 13:15:18 -07:00
|
|
|
|
|
|
|
|
enum ConvoTypeOwned<S: ExternalServices> {
|
2026-06-19 12:01:17 -07:00
|
|
|
Direct(Box<dyn Convo<S>>),
|
2026-06-15 13:15:18 -07:00
|
|
|
Group(Box<dyn GroupConvo<S>>),
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-19 12:01:17 -07:00
|
|
|
impl<S: ExternalServices> Debug for ConvoTypeOwned<S> {
|
|
|
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
|
|
|
match self {
|
|
|
|
|
Self::Direct(arg0) => f.debug_tuple("Pairwise").field(&arg0.id()).finish(),
|
|
|
|
|
Self::Group(arg0) => f.debug_tuple("Group").field(&arg0.id()).finish(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-19 08:43:55 -07:00
|
|
|
impl<S: ExternalServices> Identified for ConvoTypeOwned<S> {
|
|
|
|
|
fn id(&self) -> ConversationIdRef<'_> {
|
2026-06-15 13:15:18 -07:00
|
|
|
match self {
|
2026-06-19 12:01:17 -07:00
|
|
|
ConvoTypeOwned::Direct(convo) => convo.id(),
|
2026-06-15 13:15:18 -07:00
|
|
|
ConvoTypeOwned::Group(group_convo) => group_convo.id(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl<S: ExternalServices> Convo<S> for ConvoTypeOwned<S> {
|
|
|
|
|
fn send_content(
|
|
|
|
|
&mut self,
|
|
|
|
|
cx: &mut ServiceContext<S>,
|
|
|
|
|
content: &[u8],
|
|
|
|
|
) -> Result<(), ChatError> {
|
|
|
|
|
match self {
|
|
|
|
|
ConvoTypeOwned::Group(group_convo) => group_convo.send_content(cx, content),
|
2026-06-19 12:01:17 -07:00
|
|
|
ConvoTypeOwned::Direct(convo) => convo.send_content(cx, content),
|
2026-06-15 13:15:18 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn handle_frame(
|
|
|
|
|
&mut self,
|
|
|
|
|
cx: &mut ServiceContext<S>,
|
|
|
|
|
enc: EncryptedPayload,
|
|
|
|
|
) -> Result<ConvoOutcome, ChatError> {
|
|
|
|
|
match self {
|
|
|
|
|
ConvoTypeOwned::Group(group_convo) => group_convo.handle_frame(cx, enc),
|
2026-06-19 12:01:17 -07:00
|
|
|
ConvoTypeOwned::Direct(convo) => convo.handle_frame(cx, enc),
|
2026-06-15 13:15:18 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn wakeup(&mut self, service_ctx: &mut ServiceContext<S>) -> Result<(), ChatError> {
|
|
|
|
|
match self {
|
|
|
|
|
ConvoTypeOwned::Group(group_convo) => group_convo.wakeup(service_ctx),
|
2026-06-19 12:01:17 -07:00
|
|
|
ConvoTypeOwned::Direct(convo) => convo.wakeup(service_ctx),
|
2026-06-15 13:15:18 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|