* ci: switch the Nix cache to setup-nix-cache-action
Same as logos-chat-module#57 and logos-delivery-module#69; replaces the
per-runner GitHub-actions cache with the shared Attic cache.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* ci: retrigger
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* feat: publish key bundles and accounts via logos delivery
* chore: clear format
* feat: use protobuf for registry
* chore: update chat proto rev
* feat: merge http registry into logos delivery
* chore: renaming chat store for contact registry
A direct conversation has participants like any conversation, and listing them is part of the conversation surface, but group_members could only answer for groups: the roster accessor sat on the group-only trait, so the direct arm returned an unsupported-function error even though the membership lives in the conversation's inner group.
members moves to the Convo trait and DirectV1Convo delegates it to that inner group. add_member stays on the group-only trait, so the direct arm keeps rejecting it.
A GroupV2 add is staged as a proposal that a later commit merges, so between the two the invited member sits in the conversation's own state and nowhere in its public roster, leaving a caller no way to observe an invite in flight.
group_members now returns those invites after the committed members, flagged pending. The flag is inviter-local and transient: a conversation records only the joiners this client proposed, and drops each one as the commit admitting it lands. GroupV1 never reports a pending member, since its add merges its own commit before returning.
A GroupV2 conversation carries a shared name and description in its MLS group context, set at creation and delivered to every joiner in the welcome. The threaded client could create groups but neither set that metadata nor read it back, leaving both fields unreachable through the client API.
create_group_conversation now takes a GroupMetadata and creates the group with its name and description via create_group_convo_v2; both fields may be empty for an unnamed group. A new group_metadata getter reads a group's metadata back, erroring for a direct conversation or a legacy group that carries none. GroupMetadata is a distinct input type, kept separate from the ConvoMetadata a conversation reports back so the two can evolve independently; both are re-exported from the crate root and from logos-generic-chat.
PrivateV1 was the legacy prekey-handshake 1:1 protocol, superseded by
DirectV1 over InboxV2. Remove it and everything that existed only to
serve it.
- Delete PrivateV1Convo and its v1 inbox bootstrap: the Inbox,
Introduction, and InboxHandshake types, plus the support code only they
used (the crate::crypto module, EncryptionError, timestamp_millis, the
PrivateV1Domain HKDF domain, and the ServiceContext test doubles).
- Drop the public entry points that exposed it: Core::create_intro_bundle,
create_private_convo_v1, dispatch_to_inbox, and the matching
ChatClient::create_intro_bundle / create_conversation.
- Remove ConversationKind::PrivateV1 and its "private_v1" mapping, the
from_kind arm, and the orphaned inbox-v1 proto re-exports.
- Migrate the chat-cli /intro and /connect commands and the
message-exchange example to create_direct_conversation.
- Delete the PrivateV1 integration test; move the sqlite conversation
roundtrip test to GroupV1.
InboxOutcome, PayloadOutcome::Inbox, and ConversationClass::Private stay:
InboxV2 (DirectV1 and GroupV1 invites) still produces them.
Follow-ups, out of scope here:
- logos-chat-module still calls create_intro_bundle / create_conversation
and needs a companion change.
- remote_convo_id and EphemeralKeyStore are now vestigial (only PrivateV1
used them); dropping them is a storage-schema change for a later PR.
A group's roster changed silently. An add merges on the steward's commit-inactivity timer, and the members that receive its commit apply it, but neither surfaced any observation: Core::wakeup returned () and the client worker discarded it, and GroupV2Convo mapped only chat messages into a ConvoOutcome, so a commit produced an empty outcome. An app could learn a group grew only by re-selecting the conversation, a manual refresh, or a later message from the new member.
de-mls already reports the change: CommitApplied (adds and removes) and WelcomeReady (adds) both fire, on every member, when a commit merges. Drain them into the observation and emit a new event.
- ConvoOutcome gains members_changed, set by GroupV2Convo when a poll cycle's drained de-mls events include CommitApplied or WelcomeReady. It rides alongside content, the same way a protocol-only frame already yields content: None.
- Convo::wakeup returns a ConvoOutcome instead of (), mirroring handle_frame, so the steward's own timer-driven commit is observable. Kinds with no timers return ConvoOutcome::empty; Core::wakeup and the worker translate it through the same events_from_inbound path inbound payloads use.
- New Event::ConversationMembersChanged { convo_id }: the app re-fetches group_members. Fires on every member a commit reaches, so the inviter sees its own add land and existing members see later joins.
Update config
Update constructor calls
Enable groupContext for GV2
Remove test group type
Add test
temporary dep; waiting for upstream
Remove owner
Doc MLS extension type
Add ConvoMetaInfoVersion
Update infallable function docs
strongly typed metadata
Cleanup tests
Pin de-mls revision
full remove owner
* add WallClock into groupv2
* switch to main branch, remove custom type
---------
Co-authored-by: Jazz Turner-Baggs <473256+jazzz@users.noreply.github.com>
* 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.
* feat: separate embedded p2p delievery to its own crate
* feat: separate p2p config in its own crate
* chore: split embed module
* chore: refactor registry config
* feat: split logos chat crate
* chore: refactor
Core is no longer account-aware: the client resolves an account address
to signer ids via the account directory, and the signer's verifying-key
hex serves as registry key, inbox subscription, and Welcome routing
target end to end. The MLS credential stays the full id().
- GroupV2 reads the de-mls member id from the fetched key package and
maps it to the signer id the welcome is delivered to.
- All account machinery (directory trait, bundle codec, resolution)
moves out of core into logos-account; the RegistrationService
supertrait and Core::account_directory() are gone, and the client
holds its own directory handle.
- The account exposes functionality, never a signer: add_delegate_signer
does the lamport upsert and signs internally.
- Every client acts for an account (ChatClientBuilder::new(account)).
DelegateSigner is a pure keypair; the client composes the wire
credential from the signer and the account, so the association is
client state. addr() is the account address.
- resolve_device_ids fails fast (NotAnAccountKey / NoDeviceBundle /
Directory) instead of falling back to treating an unresolved address
as a signer id. LogosChatClient::open and chat-cli mint and publish a
dev account each launch.
- EphemeralRegistry keys key packages by hex pubkey like HttpRegistry.
Supersedes #155 (routing_id).
* chore: gate logos-delivery transport on cargo feature, not env-dependent cfg
* chore: fix clippy
* feat: logos chat client use logos delivery as default
closes: #77
The C consumer story lives downstream now: logos-chat-module wraps the
client crate and exposes its own C API. The in-tree client-ffi crate has
no consumers left, and the nim bindings still target the removed
Context-based C API.
- delete crates/client-ffi (including the message-exchange C example)
and nim-bindings
- drop core/conversations' unused safer-ffi dependency plus the leftover
C artifact crate-types: staticlib on core/conversations, cdylib on
double-ratchets (neither crate has extern "C" exports)
- flake.nix: drop the default package (it built libclient_ffi.a plus its
header); keep the logos-delivery package and the dev shell
- ci.yml: drop the C FFI smoketest steps (valgrind included), the rustup
install the smoketest no longer needs, and the nix-build job that
built the removed default package
- ADR 0001: point the FFI-compatibility driver at the downstream C API
boundary instead of crates/client-ffi
* feat: account to device store
* feat: accout traits and codec
* feat: integrate accounts abstraction
* chore: clean docs and naming
* remove account public key from payload
* chore: fix clippy
* feat: lamport check before update account store
* chore: rebase to core
* chore: register account in new core
* chore: rebase changes and use account pub for index account store
* chore: move chat store outside of libchat
* chore: use account pub for registry
The client, not the app, now drives the transport; events are delivered
asynchronously, per ADR 0001.
- ChatClient owns Arc<Mutex<Core>> + a worker thread.
- The worker select!s over the inbound and shutdown channels; Drop joins it.
Outbound runs on the caller's thread.
- A single Transport (DeliveryService + inbound()) owns both directions of the
boundary, so the client takes one transport rather than a (delivery, inbound)
pair. InProcessDelivery::new, CDelivery, and chat-cli's transports implement it.
- FFI replaces client_receive with client_push_inbound + client_poll_events.
- chat-cli drains Receiver<Event>; inbound and event channels are both crossbeam.
- Corrects ADR 0001's inbound sequence to push — the worker parks on select!,
it never polls.
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.
* feat: http server based key package registry
* chore: instructions on running the registration service
* chore: remove duplicate post param
* chore: revert out sourced account id for multi devices support
* feat: signature on account id and key packages
* chore: include http registry in contact registry module
* refactor: use device id for retrieve key package
* chore: use string for device id
* feat: server verification on the register
* chore: doc the smoke test
* chore: fix data folder non exist
* chore: use payload for register and retrieve
* chore: fix clippy
Update InboxV2 to use IdentProvider
Create Full featured Provider
Introduce MlsIdentityProvider
Flatten MLSContext
Cleanup warnings until future integration PR
remove duplicate
Update account_id comments
* feat: prefix sender id
* chore: add message struct for sender info
* chore: refactor struct name for frontier
* chore: reuse duplicate test
* chore: fix clippy
* feat: use sender_id in wire
* chore: remove result
* chore: fix nix build
* chore: bump chat_proto version
* chore(flake): accept extra system attr; add perl for openssl-sys build
forAllSystems calls the lambda with {system, pkgs}; strict
destructuring requires `..` to ignore the system attribute.
`pkgs.perl` is needed because openssl-sys is pulled vendored via
libsqlite3-sys / rusqlite / chat-sqlite, and its `perl Configure`
step needs FindBin.pm, which Fedora's system perl doesn't ship.
* feat: introduce client event system
- Core processing yields a `PayloadOutcome` enum — `Empty`, `Convo`, or
`Inbox`. `ConvoOutcome` carries a conversation id and an optional
decrypted `Content`; `InboxOutcome` adds a `NewConversation`
(id + `ConversationClass`) for a peer-initiated conversation.
- Client translates `PayloadOutcome` into app-facing `Vec<Event>`
(`ConversationStarted`, `MessageReceived`) at the boundary, so the
application loop sees discrete events rather than core types.
- MLS group welcomes produce a `ConversationStarted` event with no
initial content, fixing the silent-group-join case where the inbox
layer dropped the observation.
- C FFI exposes an `EventList` opaque type with indexed accessors and
an `Invalid` sentinel for out-of-bounds / non-applicable reads.
- Symmetric `Inbox` / `InboxV2` handlers: both return
`Result<InboxOutcome, _>` and own the persistence + ephemeral-key
cleanup for the conversations they create.
- Updated and simplified `docs/adr/0001-client-event-system.md`.
* chore(flake): bump nixpkgs to nixos-unstable-small
Temporary. The two crates.io UA fixes (NixOS/nixpkgs#512735 for
fetchCargoVendor's python-requests UA, NixOS/nixpkgs#524985 for
importCargoLock's curl UA) haven't propagated to nixos-unstable yet.
Switch to nixos-unstable-small and force logos-delivery to follow so
the smoketest gets the same fix. Revert once nixos-unstable catches up.
Refs:
- https://github.com/rust-lang/crates.io/issues/13482
- https://github.com/rust-lang/crates.io/issues/13783
- https://crates.io/data-access