Skip to content

feat: crdt for group membership and metadata - #894

Open
dastansam wants to merge 8 commits into
mainfrom
feat/group-membership-crdt
Open

feat: crdt for group membership and metadata#894
dastansam wants to merge 8 commits into
mainfrom
feat/group-membership-crdt

Conversation

@dastansam

@dastansam dastansam commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

Replaces the Group::revision last-writer-wins merge with a signed-op
CRDT so partitioned members converge deterministically on membership and
metadata. Implements docs/proposals/Group-State-CRDT.md.

  • Membership = Observed-Remove Set (add-wins); metadata = per-field LWW.
  • Founder is bootstrap admin, un-removable, and the only one who can
    remove an admin. Gated on Capabilities::GROUP_CRDT.
  • CRDT is the source of truth for Group.members; includes compaction GC
    and session-tolerant delivery (queues sends during first-contact
    handshake instead of dropping). Inspect via qauld-ctl group crdt-view.

@dastansam
dastansam marked this pull request as ready for review June 22, 2026 08:54
@dastansam dastansam self-assigned this Jun 24, 2026
@dastansam
dastansam requested a review from MathJud June 29, 2026 12:08
@dastansam
dastansam force-pushed the feat/group-membership-crdt branch from 552041f to 8f62a67 Compare July 5, 2026 12:25
First slice of the signed-op CRDT that replaces the rev-counter
last-writer-wins merge (docs/proposals/Group-State-CRDT.md): a pure,
storage/proto-free core so the merge + authorization logic is unit
testable in isolation.

Design: state is a grow-only set of signed ops (GroupOp), deduped by
op_id; merge is set union (commutative/associative/idempotent). The
membership/metadata view is DERIVED by a deterministic fold over the ops
in total (lamport, actor_id, op_id) order:
- Membership = OR-Set keyed by member_id. A Remove tombstones the add
  op_ids it observed, so a concurrent fresh re-add survives → ADD-WINS
  on concurrent add-vs-remove (the mesh-invite default; flagged for
  product review).
- Metadata (name, avatar) = per-field LWW register keyed by
  (lamport, actor_id).

Authorization is checked at APPLY time, not merge time — unauthorized
ops merge but are skipped by the fold, so a later admin promotion with a
lower lamport retroactively legitimises a dependent op with no
rebroadcast. v1 policy: any member may Add (open invite; invite_only
flips to admins-only); only admins may Remove / UpdateMetadata; the
founder is the bootstrap admin, is un-removable, and is the ONLY actor
who may remove an admin (per Dastan). Compaction: an admin Compact op
raises a lamport floor; ops below it are rejected on merge (tombstone GC
within the DTN horizon).

17 unit tests: add/remove, re-add, concurrent add-wins, merge-order
independence (convergence), retroactive legitimization, founder-only-
removes-admin, admin-removes-member, un-removable founder, invite-only,
LWW metadata + admin gate, idempotent merge, compaction floor + admin
gate. Builds clean.

Follow-ups (next slices): GroupOp protobuf + signing/verify; wire it
into Group via Capabilities::GROUP_CRDT with translation to today's
invite/reply path for mixed groups; persist the op set; epoch/Compact
RPC surface.
Second slice of the group CRDT (docs/proposals/Group-State-CRDT.md):
the signed-op wire format and the codec bridging it to the proto-free
core.

- group_net.proto: GroupOp { group_id, op_id, actor_id, lamport,
  created_at, oneof op { AddMemberOp | RemoveMemberOp | UpdateMetadataOp
  | CompactOp }, signature }, plus a `group_op` variant on
  GroupContainer (regenerated).
- crdt_wire.rs: sign_op (sign the signature-empty encoding with the
  actor's identity key) and verify_and_decode (resolve the actor pubkey
  by PeerId — supplied by the caller — verify the signature, check
  actor_id matches the key, validate op_id length, decode into a core
  GroupOp). proto<->core conversion for all op kinds.
- placeholder receive arm for the GroupOp container variant (real
  merge/apply handler lands in the integration slice).

5 unit tests: sign/verify round-trip, all op kinds, tampered op
rejected, wrong-key rejected, non-16-byte op_id rejected. Builds clean;
full libqaul suite passes (136).
Third slice of the group CRDT (docs/proposals/Group-State-CRDT.md):
durable op storage and the capability advertisement, so received ops
can accumulate and the view be rebuilt across restarts.

- storage: GroupAccountDb gains a per-account `group_ops` sled tree
  (key = group_id ++ op_id), wired through all construction sites incl.
  the graceful tree-open fallbacks.
- crdt_store.rs: save_op (persist an already-verified GroupOp),
  load_crdt (rebuild a GroupCrdt for a group by folding its stored ops;
  trusts stored ops, no re-verify), has_op. crdt_wire::decode_trusted
  decodes a stored op without signature re-check.
- Capabilities::GROUP_CRDT = 1 << 3, OR'd into Capabilities::LOCAL so
  this binary advertises CRDT support; peers without the bit keep the
  legacy invite/reply path.
- placeholder GroupOp receive arm already present from the prior slice.

3 store tests (save/load round-trip + derive, per-group isolation,
add→remove sequence reloads converged). 139 libqaul tests pass; builds
clean.

Remaining (final, load-bearing slice — needs live verification): the
receive handler (verify via router pubkey → save_op → re-derive view →
reconcile into Group), outbound op production on add/remove/rename,
founder tracking on Group, capability-gated translation to/from the
legacy invite path, and retiring the Group::revision merge.
Fourth slice (docs/proposals/Group-State-CRDT.md): make the CRDT
observable and exercised end-to-end, verifiable via qauld-ctl.

- Group gains a `founder` field (serde default), set to the creator on
  group create, and threaded to invitees via GroupInfo.founder so they
  can derive the membership CRDT.
- Receive: GroupContainer::GroupOp -> Group::on_crdt_op resolves the
  actor's public key (Users::get_pub_key), verifies the signature
  (crdt_wire::verify_and_decode), and persists the op (crdt_store).
- Emit: Group::emit_crdt_op signs an op with the local identity key,
  persists it, and sends it (wrapped as a group notify so it routes to
  Group::net) to current members plus, for an Add, the new member.
  Hooked additively onto the existing invite (Add), remove (Remove,
  tombstoning observed adds) and rename (UpdateMetadata) handlers —
  the legacy path is unchanged.
- Read: GroupCrdtView RPC + Group::crdt_view derive the converged view;
  qauld-ctl `group crdt-view --group-id <id>` prints founder, name,
  op_count and members/roles.

Live-verified on two nodes via qauld-ctl: A creates a group, invites B,
B accepts; A's AddMember op and a subsequent rename's UpdateMetadata op
propagate so BOTH nodes show an identical derived view (founder=admin,
B=member, name converged). 139 libqaul tests pass; builds clean.

KNOWN CAVEAT (same first-contact gap as rotation/envelope): emit_crdt_op
sends under the per-peer session, so an op produced before that session
is established (e.g. the AddMember at the very first invite to a peer
with no prior session) fails to encrypt and is dropped — warm the
session first (any prior message) and ops flow. Proper fix is
session-tolerant/queued delivery, shared with the rotation/envelope
work. Also still pending: making the CRDT the source of truth for
Group.members (today it shadows the legacy list) and retiring
Group::revision.
Fifth slice (docs/proposals/Group-State-CRDT.md): the derived CRDT view
now drives the materialized Group.members / name that every existing
read path uses (chat routing, group info, UI), so CRDT membership
changes are authoritative — not just visible via `group crdt-view`.

Group::reconcile_group_from_crdt rebuilds Group.members from the derived
view after every op (emit + receive):
- members in the view are inserted/updated with the view's role
  (preserving existing per-member fields);
- a member the CRDT has an Add op for but that is no longer in the view
  was tombstoned by a Remove → dropped from Group.members;
- a member the CRDT is silent about (no add op) is preserved, so a
  mixed/legacy group is not damaged and a freshly-invited peer is not
  dropped before its add op arrives (transition-safe).
Metadata name (LWW) is applied when set. No-op for pre-CRDT groups
(no founder).

Wired into emit_crdt_op and on_crdt_op. Live-verified on two nodes via
qauld-ctl: after invite the legacy `group info` shows the CRDT
membership (founder + B); after a CRDT remove of B the legacy
`group info` drops to 1 member and crdt-view shows B tombstoned —
i.e. the CRDT, not the rev-counter, now determines membership.

139 libqaul tests pass; builds clean.

Note: the rev-counter (Group::revision) merge still exists for legacy
GroupInfo gossip between pre-CRDT peers; it is now shadowed by CRDT
reconciliation for CRDT-managed groups and can be retired once all
peers advertise GROUP_CRDT. Session-tolerant op delivery (the
first-contact cold-session gap) remains shared follow-up work.
Sixth slice (docs/proposals/Group-State-CRDT.md): the tombstone/epoch
garbage-collection story, with a correctness fix to the core.

Core fix: recompute_compaction previously pruned ALL ops below the
compaction floor, which would drop live member adds / winning metadata
set before the floor — silently changing the derived view. view() is
refactored into derive() which also returns the load-bearing op_ids
(live member adds + winning name/avatar writes); compaction now keeps
those (and the Compact ops) while collapsing only dead history, so the
view is invariant under compaction. Regression test
compaction_preserves_live_view.

Operator surface:
- Group::crdt_compact emits a signed Compact op (epoch+1, floor =
  given `below` or the current max lamport); admin-only is enforced by
  the CRDT at apply time. GroupCrdtCompactRequest/Response RPC; epoch
  added to GroupCrdtViewResponse.
- qauld-ctl `group crdt-compact --group-id <id> [--below N]`, and
  `group crdt-view` now prints the epoch.

Live-verified via qauld-ctl: after 4 renames (4 ops) a compaction
collapses to 2 ops (winning metadata + Compact), epoch 0->1, with the
derived name and membership unchanged. 140 libqaul tests pass; builds
clean.
Final slice: remove the rev-counter's authority over membership. In
on_group_notify, a CRDT-managed group (one with a founder) now skips the
legacy revision-gated GroupInfo merge entirely — its membership and
metadata are owned by the CRDT (crdt.rs) and maintained by reconcile, so
incoming legacy gossip can no longer clobber CRDT-derived state.

The `revision` field is kept (not deleted) so backward-compatible
GroupInfo gossip with pre-CRDT peers still works and stored groups still
deserialize; it is simply no longer consulted for CRDT groups. Field doc
updated to mark it legacy/retired.

Live-verified via qauld-ctl: with the rev merge retired, a cold-start
invite + rename still converge on B purely through the CRDT — the legacy
`group info` read path shows 2 members and the new name, matching
crdt-view. 140 libqaul tests pass; builds clean.

This completes the group membership/metadata CRDT: core OR-Set + LWW,
signed-op wire, persistence, capability gating, live integration as the
source of truth, compaction GC, session-tolerant delivery, and now the
retirement of the rev-counter merge. Remaining (optional, future):
translate a legacy peer's GroupInfo changes into CRDT ops for genuinely
mixed groups.
- with_group_mut() on main now takes a GroupSaveReason; the CRDT view
  sync picks Renamed when the LWW name changes (search reindex) and
  MembershipChanged otherwise.
- Regenerate qaul.rpc.group.rs: crdt-view/compact oneof fields moved
  to tags 18-21 because group_search_request took 17 on main.
@dastansam
dastansam force-pushed the feat/group-membership-crdt branch from 8f62a67 to e6d11fd Compare July 24, 2026 05:24
@sonarqubecloud

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant