Skip to content

Group lifecycle: participation taxonomy + eviction detection, recovery probe, removal notices, quarantine (#727)#769

Merged
erskingardner merged 17 commits into
masterfrom
feat/group-lifecycle-taxonomy
Jul 2, 2026
Merged

Group lifecycle: participation taxonomy + eviction detection, recovery probe, removal notices, quarantine (#727)#769
erskingardner merged 17 commits into
masterfrom
feat/group-lifecycle-taxonomy

Conversation

@mubarakcoded

@mubarakcoded mubarakcoded commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Part of marmot-protocol/marmot#73 — grew from the group-lifecycle-taxonomy spec PR into the full implementation. No longer "no behavior change": after the spec redesign (removal commit as sole membership authority, discussion in the thread below), all follow-up slices landed here as individually-green, bisectable commits so the repo can reach zero open PRs before the repo move.

Spec (spec/, commits 7e4b3ddc..cc08bd77)

  • Participation dimension (Member/Left/Evicted/Quarantined{reason}), orthogonal to the convergence lifecycle.
  • Removal commit = sole membership authority. Eviction detection is a delivery problem, not an inference problem: the removal commit stays readable to the removed member forever (last-member-epoch protection). Discovery mechanisms: transport missed-input recovery probe, kind-451 removal notices, bounded quarantine hold. Eventual-detection guarantee stated explicitly.
  • Transport agnosticism enforced via the transports/README.md checklist (missed-input recovery + notice envelope per binding); kind 451 registered; membership-interval PreMembership in errors.md.

Implementation (one commit per package)

Commit Package
e592debb A — durable participation state machine on the Group record (snapshot/rollback-coherent), transitions on BOTH apply paths (direct ingest + convergence replay, fixing the MemberLeft/MemberRemoved attribution asymmetry), rejoin reinstatement, invariant repair at the UseAfterEviction/is_active arms, GroupEvent::ParticipationChanged, CgkaEngine::participation()
b3814eff BStale(Evicted) classification replacing the PeelFailed collapse; tombstoned groups no longer misreport UnknownGroup
1b18f99a A2 — deterministic subscription teardown + routing exclusion when participation ends (relay-plane audit: dead groups kept live subscriptions forever)
47c3780e Cthe marmot-protocol/mdk#376 root cause: the persisted transport cursor advanced on undecryptable input, permanently skipping the relay window containing the missed removal commit. Cursor now freezes at last-consumed input; new TransportAdapter::backfill_account_group probe (stored-route pinned, live-subscription-id reuse) driven with exponential backoff from the app
2184e5e5 D — kind-451 removal notices: NIP-59 wrap embedding the exact published 445 commit (tamper-rejected on peel), committer-side send from the runtime publish loop (origin-commit correlation), engine inbox re-injection into ordinary ingest — the notice carries zero authority
8818f476 E — quarantine enforced (#688/#689): inbound withheld (retained, Stale(Quarantined)), live convergence excluded, quarantine_group/resolve_group_quarantine as the explicit recovery transition; probe dry-bound → Quarantined(PendingMembership)
1adc7162 F — retained membership epoch intervals (#622); PreMembership terminal classification on the too-distant-in-the-past arm (welcome-before-commit deliberately keeps AlreadyAtEpoch); empty history fails open
2dbb83ae G — participation surfaced through AppGroupMlsState + FFI as stable tags; composer gating unblocked (whitenoise)

marmot-protocol/mdk#376 end-to-end

Cursor can't skip the removal window (C) → probe recovers a missed commit (C) → notice delivers it even across relay partitions (D) → engine transitions participation on either apply path (A) → app tears down subscriptions (A2) and surfaces the state (G). Bounded failure honestly parks in quarantine (E) instead of lying about membership.

Verification

just fast-ci green; full cargo test --workspace green (103 test binaries), including new coverage at every layer: engine (eviction/left/rejoin/gap-PreMembership/quarantine-withhold-resolve), peeler (notice wrap/peel/tamper), runtime (committer notice publish, backfill routing), adapter (probe reissues stored route), app (cursor gating matrix, probe backoff, teardown). Simulator-tier scenario vectors for the reorder flows are sensible post-move hardening; the flows are currently pinned by the engine-tier integration tests listed above.

Summary by CodeRabbit

  • New Features
    • Added participation states for groups (member, left, evicted, quarantined) and exposed stable participation tags through group state and event streams.
    • Added removal-notice support and membership recovery probing via group backfill.
    • Quarantined groups are consistently withheld from live access until recovered.
  • Bug Fixes
    • Improved handling of stale outcomes for evicted and pre-membership messages, including correct retry behavior.
    • Cursor advancement is now suppressed on peel-failed inbox outcomes, triggering membership recovery instead.
  • Tests
    • Expanded snapshot and ingest coverage for the new stale/participation variants.

@coderabbitai

coderabbitai Bot commented Jul 1, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

The PR adds participation state and membership interval tracking to group records, threads that state through engine ingest, lifecycle, and convergence paths, and extends transport handling with removal notices and group backfill. App routing, probing, storage, and FFI layers are updated to surface the new participation state.

Changes

Participation, engine state, and specs

Layer / File(s) Summary
Traits, storage shape, and protocol spec
crates/traits/src/group.rs, crates/traits/src/engine.rs, crates/traits/src/ingest.rs, crates/traits/src/peeler.rs, crates/traits/src/transport_adapter.rs, crates/traits/src/lib.rs, crates/traits/tests/snapshots.rs, spec/protocol-core/group-state.md, spec/protocol-core/inbound-processing.md, spec/foundation/errors.md
Adds participation, quarantine, membership intervals, removal-notice, and backfill types and APIs; updates snapshots and protocol text for new stale categories and participation states.
Engine participation and lifecycle persistence
crates/cgka-engine/src/engine.rs, crates/cgka-engine/src/group_lifecycle.rs, crates/cgka-session/src/lib.rs, crates/cgka-conformance-simulator/src/client.rs, crates/cgka-conformance-simulator/tests/openmls_replay_probe.rs, crates/cgka-engine/tests/hydration_quarantine.rs, crates/storage-sqlite/src/storage/test_support.rs
Persists participation on create/join, updates membership intervals and quarantine handling, exposes participation lookup and removal-notice wrapping, and updates test fixtures.
Ingest, replay, and departure classification
crates/cgka-engine/src/message_processor/ingest.rs, crates/cgka-engine/src/distributed_convergence.rs, crates/cgka-engine/src/openmls_projection.rs, crates/cgka-engine/src/audit_helpers.rs, crates/cgka-engine/tests/ingest.rs, crates/cgka-engine/tests/invite_leave.rs
Classifies eviction, quarantine, and pre-membership cases, propagates replay metadata, and emits participation/departure events with updated tests.
Removal notices and transport backfill
crates/transport-nostr-peeler/src/lib.rs, crates/transport-nostr-peeler/src/peeler.rs, crates/marmot-account/src/runtime.rs, crates/marmot-account/tests/runtime.rs, crates/traits/src/transport_adapter.rs, crates/marmot-app/src/relay_plane/*, crates/transport-nostr-adapter/src/lib.rs, crates/transport-nostr-adapter/tests/inbound_routing.rs
Adds removal-notice rumor handling, backfill request plumbing, and best-effort removal-notice publishing plus adapter tests.
App routing, probing, and FFI exposure
crates/marmot-app/src/client/*, crates/marmot-app/src/groups.rs, crates/marmot-app/src/lib.rs, crates/marmot-app/src/runtime/event_routing.rs, crates/marmot-uniffi/src/conversions/*, crates/storage-sqlite/src/account_projection*, crates/marmot-app/src/tests.rs
Adds membership probe state, removed-membership routing filters, participation tags, event routing updates, FFI propagation, and storage queries/tests.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is specific and accurately reflects the main additions: participation taxonomy, eviction detection, recovery probing, removal notices, and quarantine.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/group-lifecycle-taxonomy

Comment @coderabbitai help to get the list of available commands.

@erskingardner erskingardner left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adversarial pass found two contract issues in the new lifecycle taxonomy. I left them inline where the taxonomy is introduced.

Comment thread spec/protocol-core/group-state.md Outdated

- `Member`: the local identity is present in the group's canonical roster. This is the only participation state in
which a client MAY prepare local group-state commits or emit delivered app payloads for the group.
- `Evicted`: the local identity is authoritatively no longer a member of the group. The group is inactive for this

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] This taxonomy collapses voluntary departure into eviction/removal semantics. Existing protocol and event surfaces distinguish SelfRemove/MemberLeft from admin removal/MemberRemoved, but the only non-member participation state here is Evicted, and the public-surface rule below requires callers to distinguish only Member/Evicted/Quarantined/unknown. That leaves no way for APIs or UI to represent "I left voluntarily" without labeling it as eviction/removal. Please either make this state neutral (for example NonMember/Inactive with a reason) or add a separate Left/Departed terminal state.

- commits that fork from outside the rollback horizon: these are ineligible for branch selection (see
[convergence.md](./convergence.md), "Eligibility") and, when their source epoch is also older than the retained
anchor, are reported as `BeyondAnchor`.
- messages that predate the local identity's membership and can never be decrypted by this client

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] PreMembership needs to be defined against membership intervals, not a single membership boundary. The same PR allows rejoin/reinstatement after eviction; after leave/evict/rejoin, messages from a prior valid membership interval may still be recoverable from retained state, while messages between eviction and rejoin are outside membership but not simply "before membership". Please specify this as outside any valid local membership interval, and state what interval data implementations must retain/classify against.

@erskingardner

Copy link
Copy Markdown
Member

Reviewed the taxonomy against what OpenMLS actually surfaces. The types + vocabulary read clean and the "no behavior change" claim checks out. One thing to reconcile before the follow-up slices build on it:

The spec's path-2 eviction ("removal commit never applied … MLS reports that the local identity has been evicted") has no OpenMLS realization. OpenMLS has exactly one eviction mechanism, and it's entirely gated on merging the removal commit:

  • merge_staged_commit sets the group Inactive only if staged_commit.self_removed() (openmls 0.8.1 processing.rs:271).
  • is_active() is then just !Inactive, and UseAfterEviction is a guard process_message/unprotect_message raise because the group is already Inactive (processing.rs:155) — i.e. it's path-1 aftermath, not a fresh discovery.
  • Our own clean-path detection agrees: we diff the roster before/after merge and check whether self_id fell out (ingest.rs:741, :820) — again, only after applying the commit.

So if the removal commit is never applied (the relay-reordering case the spec calls "a required fallback, not an edge case"), the group stays Operational/is_active()==true, and the later post-eviction message just fails to decrypt — wrong-epoch / no matching secrets, indistinguishable from a future-epoch or corrupt message. MLS's remove authority is carried only by the removal commit; no commit applied → no authoritative "you're evicted" signal.

Two concrete asks:

  1. Reword path 2 so it doesn't claim "MLS reports … evicted" without the commit — eviction there has to be derived above MLS (fetch + apply the removal commit, or infer from roster/relay state), not read off an MLS error.
  2. Silent eviction: removed member is never notified when the removal Commit isn't applied (UseAfterEviction swallowed) mdk#376 plans to emit SelfEvicted at "the ingest UseAfterEviction seam" — but that seam only fires once the group is already Inactive (removal merged), so it covers path 1's aftermath, not path 2. Worth spelling out which seam is actually meant to observe path 2, since as written none does.

(Substrate for representing the state already exists — we keep a tombstoned group after removal per engine.rs:953. What's missing is exactly the authoritative surfacing #769/#722 name, so the direction is right — just the path-2 framing that I'd tighten.)

@erskingardner

Copy link
Copy Markdown
Member

Overall the idea of this participation state makes sense to me but we need to be really careful that it's actually using mechanics that exist and are actually usable by us at the point in which we need to change them.

@mubarakcoded

mubarakcoded commented Jul 2, 2026

Copy link
Copy Markdown
Contributor Author

@erskingardner Thanks — all three land, and the OpenMLS archaeology is the important one. You're right: I claimed an MLS mechanism that doesn't exist. Verified against our own engine (ingest.rs:129 guards on is_active(); the roster diff at ~741 is post-merge; the UseAfterEviction arm is only reachable once the group is already inactive), so the whole "path 2 = MLS reports evicted" framing was wrong. Fixed in the last two commits:

A — path 2 has no MLS realization (conceded). Reworded group-state.md "Reaching a non-member state": removal authority lives only in the removal commit, so with it unmerged the group stays active and a later message is just a wrong-epoch / no-secret failure — indistinguishable at the MLS layer. Path 2 is now explicitly derived above MLS — obtain+apply the missing removal commit (convergence backfill → path 1), or infer from authenticated roster/relay state — and the spec now states UseAfterEviction is path-1 aftermath, not a fresh discovery. Removed the SelfEvicted named outcome from errors.md (it encoded the wrong "MLS emits eviction" model); non-membership is a participation state, not an inbound disposition. The undecryptable post-removal message now follows ordinary rules (deferred while the commit may still be fetched, terminal only when not).

Your seam question, answered in-spec: #722's UseAfterEviction seam is the wrong seam for path 2 — it only fires post-merge. Path 2 detection has to be convergence backfill of the removal commit (then the existing clean path fires) or an above-MLS roster/relay inference. I'll carry that into the marmot-protocol/mdk#376 slice rather than the seam as originally written.

B — voluntary leave vs eviction. Added a distinct Left participation state (mirrors MemberLeft vs MemberRemoved); the public-surface rule now distinguishes live-member / Left / Evicted / Quarantined / unknown with the reason preserved. GroupParticipation gains Left (+ snapshot).

C — membership intervals. PreMembership is redefined as "outside every interval during which this identity was a member," with the note that a group may be left/removed and rejoined, so membership is a set of epoch intervals; input inside a prior valid interval may still be recoverable and is not PreMembership. Classification is against retained per-group membership intervals.

No behavior change still holds (additive types; spec + GroupParticipation). fmt/clippy/cargo test -p cgka-traits green.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@spec/foundation/errors.md`:
- Around line 24-26: Clarify the `pre_membership` error definition in the errors
spec so it only applies when retained membership history exists for the identity
and the input falls outside those retained membership intervals. Update the
wording around `PreMembership` in the documented error enum/section to exclude
the no-state case, which should remain covered by `unknown_group`, and ensure
any repeated description in the later spec section stays consistent with this
scope.

In `@spec/protocol-core/group-state.md`:
- Around line 138-177: The path-2 fallback in the participation state rules
loses the distinction between `Left` and `Evicted`, which the later contract
requires preserving. Update the fallback wording near the non-member transition
logic to say that authenticated roster/relay evidence may establish
non-membership, but the client MUST still carry forward the best-supported
reason (`Left` vs `Evicted`) when available; reference the `Left`/`Evicted`
definitions and the “Removal commit not applied” section so the preserved reason
stays consistent with the public-surface requirement.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 90a05c87-3620-48d8-b525-112d3f2b4d41

📥 Commits

Reviewing files that changed from the base of the PR and between 5860b73 and 4fdc765.

⛔ Files ignored due to path filters (4)
  • crates/traits/tests/snapshots/snapshots__participation_evicted.snap is excluded by !**/*.snap
  • crates/traits/tests/snapshots/snapshots__participation_left.snap is excluded by !**/*.snap
  • crates/traits/tests/snapshots/snapshots__participation_member.snap is excluded by !**/*.snap
  • crates/traits/tests/snapshots/snapshots__participation_quarantined.snap is excluded by !**/*.snap
📒 Files selected for processing (5)
  • crates/traits/src/group.rs
  • crates/traits/tests/snapshots.rs
  • spec/foundation/errors.md
  • spec/protocol-core/group-state.md
  • spec/protocol-core/inbound-processing.md
🚧 Files skipped from review as they are similar to previous changes (2)
  • spec/protocol-core/inbound-processing.md
  • crates/traits/src/group.rs

Comment thread spec/foundation/errors.md Outdated
Comment thread spec/protocol-core/group-state.md Outdated
@erskingardner

Copy link
Copy Markdown
Member

Pushed cc08bd7, which restructures the design around the conclusion from the thread above: the fragility was treating discovery failures as an epistemology problem. The commit makes the removal commit the sole membership authority and demotes everything else to delivery mechanisms for it.

Why this closes the remaining hole. The removal commit is protected under the removed member's last epoch, so it stays readable to them no matter how late it arrives. That means the imperfect-information problem was never authentication — it's delivery of one specific, always-consumable artifact. So instead of a second "derive non-membership above MLS" path (which had no realizable oracle), group-state.md now specifies three discovery mechanisms:

  1. Recovery probe — undecryptable traffic MUST trigger the active transport's missed-input recovery, bounded around the last input successfully consumed. Recovered commit → ordinary inbound flow → the applied-removal transition fires late but identically.
  2. Removal notice (new, member-departure.md) — the committer of a removal SHOULD send each removed member a notice via their account inbox. Zero authority: the receiver only acts by validating and applying the carried commit through the normal pipeline, so a forged notice is inert (worst case: a validation attempt an actual removal would already justify).
  3. Bounded hold — probes dry past a local policy bound → Quarantined(pending_membership), still probing, asserting nothing. Quarantine now carries a reason (pending_membership vs integrity_hold) because the two holds have opposite exits (resolution vs repair).

The spec also now states the irreducible limit honestly: a removed client that receives nothing is indistinguishable from a member of a quiet group, so the guarantee is eventual detection, with the mechanisms above making it fast in practice.

Transport agnosticism enforced, not assumed. Protocol-core names only capability-neutral obligations (missed-input recovery, recipient inbox). The transports/README.md checklist now requires every binding to state its recovery mechanism (or delivery guarantees making it unnecessary) and its removal-notice envelope (or that it carries none). quic.md answers both explicitly; all Nostr specifics live in nostr.md.

Nostr realization. Kind 451 (registered) removal notice rumor, gift-wrapped like welcomes (105913 → unsigned 451), addressed to the removed member's 10050 inbox. content embeds the full kind-445 removal-commit event (NIP-18 stringified-event convention) — no fetch round-trip, no relay-retention dependency; h/e tags cross-check; embedded event passes identical validation to a fetched one. The membership backfill probe (since = last consumed 445 minus slack) is named as this binding's missed-input recovery.

Types. GroupParticipation::Quarantined now carries QuarantineReason { PendingMembership, IntegrityHold } (snapshot-locked; still no consumers, so the additive/no-behavior-change claim holds). This also re-scopes marmot-protocol/mdk#376: the seam is not UseAfterEviction but (a) the post-merge roster diff (exists), (b) recovery-probe wiring, (c) kind-451 send/receive.

Verified: just fast-ci green, cargo test -p cgka-traits 90/90, protocol-core transport-leak grep clean.

erskingardner added a commit that referenced this pull request Jul 2, 2026
…emoval commits

Package A of the #769 follow-ups: participation transitions now have exactly
one authority — the applied removal commit — realized on BOTH apply paths.

- traits: Group gains a serde-defaulted `participation` field (legacy JSON
  records decode as Member; regression-tested). Living on the durable record
  means it survives live-OpenMLS teardown and rolls back with fork-recovery
  snapshots (a rolled-back removal also rolls back the transition).
  GroupEvent::ParticipationChanged surfaces transitions;
  CgkaEngine::participation() exposes Member/Left/Evicted/Quarantined vs
  None ("no such group") per the public-surface rule in group-state.md.
- engine direct seam (ingest.rs): self in the post-merge removed set →
  Left when the commit consumed our own SelfRemove, else Evicted.
- engine convergence seam: CommitStaged replay observations now carry
  self_remove_senders (resolved pre-merge, like the direct seam), so the
  replay path attributes MemberLeft to the leaver AND transitions self
  participation identically — the Left/Evicted distinction is no longer
  path-dependent (fixes the asymmetry documented in invite_leave.rs).
- rejoin: do_join_welcome resets participation to Member (fresh membership
  grant is the reinstatement path) and emits the transition when the prior
  record was non-member.
- inactive-group ingest arms (is_active()==false, UseAfterEviction): now
  invariant repairs — a record still claiming Member on an MLS-inactive
  group moves to Quarantined(IntegrityHold) instead of the signal being
  swallowed (the #722 arm); never fabricates a Left/Evicted reason.
- uniffi: ParticipationChanged FFI variant with stable low-cardinality
  participation tags.

Tests: invite_leave (Evicted via convergence apply; Left from consumed
SelfRemove; Member restored + event on re-add welcome; remaining member
unaffected), traits legacy-record decode + snapshots. Engine 18/18 test
binaries green; workspace tests green; clippy clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
erskingardner added a commit that referenced this pull request Jul 2, 2026
Package B of the #769 follow-ups: stop collapsing eviction-adjacent inbound
into the generic PeelFailed bucket.

- traits: StaleReason::Evicted — inbound for a group the local identity is
  no longer a member of; maps to the `evicted` category / `stale`
  disposition in spec/foundation/errors.md. Snapshot-locked. Classification
  never *drives* the participation transition (that stays with the applied
  removal commit, package A).
- engine: three arms now classify Evicted instead of PeelFailed/
  UnknownGroup:
  1. live MLS state inactive (merged removal) at ingest;
  2. the UseAfterEviction guard (path-1 aftermath);
  3. no live MLS state but the durable record says Left/Evicted — the
     tombstoned-group case after teardown, previously misreported as
     UnknownGroup (errors.md scopes unknown_group to "no state at all");
     persisted Failed (terminal) instead of Retryable.
- audit: stable "evicted" stale-reason string.

Test: readd_after_remove now asserts an evicted member ingesting the re-add
commit gets Stale(Evicted) — an evicted client cannot apply a later commit
and must not try — while the fresh Welcome still reinstates. PreMembership
classification is deferred to the membership-intervals package where it can
actually fire.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
erskingardner added a commit that referenced this pull request Jul 2, 2026
Package A2 of the #769 follow-ups — closes the relay-plane audit finding
that a removed group's subscriptions could persist forever.

Before: post-removal cleanup was gated on `state.groups.len() != before`
(sync.rs), which a convergence-applied removal never trips (the group record
is retained by design), and `refresh_group_routes` unconditionally re-added
every retained group's route — so an evicted group kept its relay
subscriptions and could even be silently re-added after a transient drop.
The adapter's sync_account_groups diff was already correct; the app simply
never handed it a reduced set.

Now, driven by the authoritative GroupEvent::ParticipationChanged (pkg A):
- Left/Evicted → self_membership flag (chat-list row stays renderable per
  the left-vs-removed distinction), route removed, transport groups synced
  immediately → adapter unsubscribes;
- Quarantined → route removed + synced (withheld from live processing; no
  membership flag write — quarantine asserts nothing about membership);
- Member (verified rejoin) → flag cleared, route re-added, synced.
- refresh_group_routes now filters groups whose durable self_membership is
  'removed', so no later refresh resurrects a dead group's subscription
  (survives restart via the existing account_groups projection).
- AppTransportRouting::remove_group + storage
  account_group_ids_with_removed_membership() query.

Tests: routing remove_group unit (targeted + idempotent), storage
removed-membership query roundtrip incl. rejoin flip-back. marmot-app +
storage-sqlite suites green; clippy clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
erskingardner added a commit that referenced this pull request Jul 2, 2026
…overy probe

Package C of the #769 follow-ups — the #722 root-cause fix.

The persisted transport cursor previously advanced after EVERY ingest,
including deliveries that failed outer decryption. Since the cursor becomes
the relay `since` filter, one post-removal (undecryptable) message pushed
the window past the missed removal commit's timestamp and no future
subscription ever fetched it: deterministic, permanent silent eviction.

Cursor fix (marmot-app): `outcome_advances_transport_cursor` — an
undecryptable delivery (Stale/PeelFailed) freezes the cursor at the last
input successfully consumed (the spec's recovery-window anchor); every
durably-accounted outcome (processed, buffered-for-convergence,
terminal-stale incl. Evicted) still advances it. Consumed input for a group
also resets that group's probe budget.

Membership recovery probe (spec group-state.md "Reaching a non-member
state", realized per transports/nostr.md "Membership backfill probe"):
- traits: TransportGroupBackfill + TransportAdapter::backfill_account_group.
- nostr adapter: re-issues the group's STORED route (caller endpoints
  ignored — a stale caller cannot point the probe at rogue relays) with the
  widened `since`, reusing the live subscription id so the relay replaces
  and replays instead of stacking duplicates; typed rejection for unknown
  groups.
- relay-plane wrapper: endpoint safety sanitization like group sync.
- marmot-account: backfill_transport_group resolves the routed subscription;
  routeless (withheld) groups are a no-op.
- marmot-app driver: undecryptable delivery for a known group triggers the
  probe with exponential backoff (60s doubling, 1h cap) anchored at the
  newest consumed app event minus a 300s slack; no consumed input → full
  re-fetch (dedup collapses overlap).

Tests: cursor-gating matrix (PeelFailed freezes; all else advances), probe
backoff schedule (base/double/cap, no overflow), runtime forwards the routed
subscription + since and no-ops without a route, adapter reissues the stored
route with probe window + rejects unknown groups + reuses the subscription
id. Workspace clippy clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
erskingardner added a commit that referenced this pull request Jul 2, 2026
…ceive

Package D of the #769 follow-ups: the removal notice mechanism from
spec/transports/nostr.md ("Removal notice delivery") and
spec/protocol-core/member-departure.md ("Removal notices").

Wire shape (transport-nostr-peeler): NIP-59 gift wrap (1059 → 13 seal →
unsigned kind-451 rumor) addressed to the removed member. The rumor embeds
the exact published kind-445 removal-commit event as stringified JSON
(NIP-18 convention) with h/e cross-check tags — no fetch round-trip, no
relay-retention dependency, and the gift wrap leaks no group id to relays.
Peel side verifies the embedded event's id + signature and full kind-445
envelope validation before returning PeeledContent::RemovalNotice; a
tampered embed is rejected, so a forged notice can never inject bytes the
committer did not publish.

Authority stays with the commit: the engine's inbox seam re-injects the
embedded, transport-validated group message into ordinary do_ingest —
dedup, validation, and (when it really is our removal commit) the package-A
participation transition all apply as if it arrived on the group stream.
The notice itself changes nothing.

Committer send seam (marmot-account runtime): after this device's published
+ confirmed commit removes members (correlated by origin_commit_id against
the commits published in the same drain — only the committer holds the
published wire event, matching the spec's SHOULD), wrap per removed member
and publish to their cached inbox route. Every notice failure (no inbox
route, binding without notice support, publish error) is logged and
swallowed — a delivery aid must never fail the commit flow.

Plumbing: TransportPeeler::wrap_removal_notice and
CgkaEngine::wrap_removal_notice both default to Ok(None) ("this binding
does not carry removal notices") so mocks and future bindings stay
compiling and opt in explicitly; session passthrough; kind 451 constant.

Tests: peeler wrap/peel roundtrip (byte-identical embed, no relay-visible
group id) + non-445-embed rejection + tampered-embed rejection; runtime
committer test (commit publish then inbox-targeted notice carrying the
published commit); engine re-injection test (embedded message classifies
through ordinary ingest; non-group embeds rejected). Workspace clippy clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
crates/cgka-engine/src/message_processor/ingest.rs (1)

67-89: 🚀 Performance & Scalability | 🔵 Trivial | 🏗️ Heavy lift

Ordinary Welcomes now get peeled twice.

When peel_welcome succeeds and returns PeeledContent::Welcome (not RemovalNotice), this if let chain doesn't match and execution falls through unchanged to the existing self.do_join_welcome(msg.clone()) call below, which re-runs self.peeler.peel_welcome(&welcome_msg).await internally (group_lifecycle.rs). Every successful real-Welcome ingest now performs the NIP-59 gift-wrap+seal unwrap/decrypt twice instead of once.

Consider threading the already-peeled content into do_join_welcome (e.g. an Option<PeeledMessage> parameter it uses instead of re-peeling when present) to avoid the duplicate crypto work. Please also confirm peel_welcome has no single-use/replay-sensitive state that could make calling it twice on the same message unsafe (vs. just wasteful).

♻️ Illustrative direction (needs a matching change in group_lifecycle.rs's do_join_welcome to accept pre-peeled content)
-        if let Ok(peeled) = self.peeler.peel_welcome(msg).await
-            && let PeeledContent::RemovalNotice { embedded } = peeled.content
+        let peeled = self.peeler.peel_welcome(msg).await;
+        if let Ok(peeled_ok) = &peeled
+            && let PeeledContent::RemovalNotice { embedded } = &peeled_ok.content
         {
             if !matches!(
                 embedded.envelope,
                 cgka_traits::transport::TransportEnvelope::GroupMessage { .. }
             ) {
                 return Ok(IngestOutcome::Stale {
                     reason: StaleReason::PeelFailed,
                 });
             }
-            return Box::pin(self.do_ingest(embedded)).await;
+            return Box::pin(self.do_ingest(embedded.clone())).await;
         }

-        // Reuse the existing join_welcome machinery. Map its error shapes
-        // to typed stale reasons where applicable.
-        match self.do_join_welcome(msg.clone()).await {
+        // Reuse the existing join_welcome machinery, passing the already-
+        // peeled content so a real Welcome is not unwrapped twice.
+        match self.do_join_welcome_from_peeled(msg.clone(), peeled).await {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/cgka-engine/src/message_processor/ingest.rs` around lines 67 - 89, The
welcome ingest path is peeling the same message twice when `peel_welcome`
returns `PeeledContent::Welcome`, because `ingest` falls through to
`do_join_welcome` which peels again internally. Update `ingest` and
`group_lifecycle::do_join_welcome` to thread through the already-peeled welcome
content (for example via an optional pre-peeled parameter) and reuse it instead
of calling `self.peeler.peel_welcome` a second time. Keep the existing
removal-notice handling in `PeeledContent::RemovalNotice`, and verify
`peel_welcome` does not depend on single-use state before reusing the peeled
result.
crates/cgka-engine/src/engine.rs (1)

922-955: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider moving participation-transition behavior out of engine.rs, and pair it with an audit record.

Two related points on set_group_participation:

  1. It implements real behavior (load/compare/persist/emit) directly in engine.rs, whereas the file's role here is meant to be construction and trait dispatch, with behavior living in sibling modules such as group_lifecycle.rs (which already owns related group-record mutation helpers). As per path instructions, "In engine.rs, own construction and trait dispatch while keeping behavior in focused sibling modules."
  2. Sibling state transitions in this file (quarantine_stored_group_on_hydrate, the PendingCommitRecovered path) emit an explicit audit record alongside their events_buf push; this method only pushes the GroupEvent. Given the PR spec calls out authoritative eviction and quarantine consistency as security-relevant, a matching audit record would keep the forensic trail consistent with how other lifecycle transitions in this file are recorded.
♻️ Illustrative sketch (exact placement/audit-event shape TBD by a maintainer)
-    pub(crate) fn set_group_participation(
-        &mut self,
-        group_id: &GroupId,
-        participation: cgka_traits::GroupParticipation,
-    ) -> Result<(), EngineError> {
-        let mut group = match self.storage.get_group(group_id) {
-            Ok(group) => group,
-            Err(cgka_traits::StorageError::NotFound) => return Ok(()),
-            Err(e) => return Err(EngineError::Backend(format!("get_group: {e:?}"))),
-        };
-        if group.participation == participation {
-            return Ok(());
-        }
-        group.participation = participation;
-        self.storage
-            .put_group(&group)
-            .map_err(|e| EngineError::Backend(format!("put_group: {e:?}")))?;
-        self.events_buf
-            .push_back(cgka_traits::engine::GroupEvent::ParticipationChanged {
-                group_id: group_id.clone(),
-                participation,
-            });
-        Ok(())
-    }
+    pub(crate) fn set_group_participation(
+        &mut self,
+        group_id: &GroupId,
+        participation: cgka_traits::GroupParticipation,
+    ) -> Result<(), EngineError> {
+        // Delegate the load/compare/persist behavior to group_lifecycle.rs.
+        let Some(event) = crate::group_lifecycle::transition_group_participation(
+            &self.storage,
+            group_id,
+            participation,
+        )? else {
+            return Ok(());
+        };
+        self.audit_group(group_id, /* forensic event mirroring `event` */ todo!());
+        self.events_buf.push_back(event);
+        Ok(())
+    }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/cgka-engine/src/engine.rs` around lines 922 - 955, Move the
participation transition logic out of engine.rs and into the sibling module that
owns group record mutations, such as group_lifecycle, while keeping
set_group_participation as a thin dispatch point. Preserve the current
load/compare/persist/emit flow and update the helper that handles
GroupId/GroupParticipation transitions so engine.rs only delegates. Also add a
matching audit record alongside the existing events_buf push, following the
pattern used by quarantine_stored_group_on_hydrate and PendingCommitRecovered so
participation changes have the same forensic trail.

Source: Path instructions

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/marmot-account/src/runtime.rs`:
- Around line 471-517: The removal-notice logging in send_removal_notices
currently emits raw errors, which may expose GroupId, MemberId, or backend text
from EngineError and TransportAdapterError. Update the tracing::warn!/debug!
calls in the wrap_removal_notice, publish_target, and adapter.publish paths to
keep the existing error_code but replace %e with a redacted, generic summary
that does not include the underlying error payload. Use the same method name and
flow in runtime.rs to locate the three log sites and make the messages safe for
production logs.

In `@crates/marmot-app/src/client/sync.rs`:
- Around line 640-653: Clamp the backfill anchor before computing `since` in
`sync.rs` so a sender-controlled `recorded_at` cannot push recovery into the
future. Update the `anchor`/`since` flow in `messages_with_query` handling to
bound the timestamp against a safe upper limit (for example, current time or
another trusted clock source) before applying `MEMBERSHIP_BACKFILL_SLACK_SECS`.
Keep the change localized around the `anchor` mapping and `since` derivation so
the cursor-freeze recovery path stays valid.

---

Nitpick comments:
In `@crates/cgka-engine/src/engine.rs`:
- Around line 922-955: Move the participation transition logic out of engine.rs
and into the sibling module that owns group record mutations, such as
group_lifecycle, while keeping set_group_participation as a thin dispatch point.
Preserve the current load/compare/persist/emit flow and update the helper that
handles GroupId/GroupParticipation transitions so engine.rs only delegates. Also
add a matching audit record alongside the existing events_buf push, following
the pattern used by quarantine_stored_group_on_hydrate and
PendingCommitRecovered so participation changes have the same forensic trail.

In `@crates/cgka-engine/src/message_processor/ingest.rs`:
- Around line 67-89: The welcome ingest path is peeling the same message twice
when `peel_welcome` returns `PeeledContent::Welcome`, because `ingest` falls
through to `do_join_welcome` which peels again internally. Update `ingest` and
`group_lifecycle::do_join_welcome` to thread through the already-peeled welcome
content (for example via an optional pre-peeled parameter) and reuse it instead
of calling `self.peeler.peel_welcome` a second time. Keep the existing
removal-notice handling in `PeeledContent::RemovalNotice`, and verify
`peel_welcome` does not depend on single-use state before reusing the peeled
result.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 2b7cfabf-6890-4795-9428-202777c10d28

📥 Commits

Reviewing files that changed from the base of the PR and between cc08bd7 and 2184e5e.

⛔ Files ignored due to path filters (2)
  • crates/traits/tests/snapshots/snapshots__group.snap is excluded by !**/*.snap
  • crates/traits/tests/snapshots/snapshots__stale_evicted.snap is excluded by !**/*.snap
📒 Files selected for processing (37)
  • crates/cgka-conformance-simulator/src/client.rs
  • crates/cgka-conformance-simulator/tests/openmls_replay_probe.rs
  • crates/cgka-engine/src/audit_helpers.rs
  • crates/cgka-engine/src/distributed_convergence.rs
  • crates/cgka-engine/src/engine.rs
  • crates/cgka-engine/src/group_lifecycle.rs
  • crates/cgka-engine/src/message_processor/ingest.rs
  • crates/cgka-engine/src/openmls_projection.rs
  • crates/cgka-engine/tests/hydration_quarantine.rs
  • crates/cgka-engine/tests/ingest.rs
  • crates/cgka-engine/tests/invite_leave.rs
  • crates/cgka-session/src/lib.rs
  • crates/marmot-account/src/runtime.rs
  • crates/marmot-account/tests/runtime.rs
  • crates/marmot-app/src/client/mod.rs
  • crates/marmot-app/src/client/sync.rs
  • crates/marmot-app/src/groups.rs
  • crates/marmot-app/src/lib.rs
  • crates/marmot-app/src/relay_plane/mod.rs
  • crates/marmot-app/src/relay_plane/safety.rs
  • crates/marmot-app/src/runtime/event_routing.rs
  • crates/marmot-app/src/tests.rs
  • crates/marmot-uniffi/src/conversions/event.rs
  • crates/storage-sqlite/src/account_projection.rs
  • crates/storage-sqlite/src/account_projection/tests.rs
  • crates/storage-sqlite/src/storage/test_support.rs
  • crates/traits/src/engine.rs
  • crates/traits/src/group.rs
  • crates/traits/src/ingest.rs
  • crates/traits/src/lib.rs
  • crates/traits/src/peeler.rs
  • crates/traits/src/transport_adapter.rs
  • crates/traits/tests/snapshots.rs
  • crates/transport-nostr-adapter/src/lib.rs
  • crates/transport-nostr-adapter/tests/inbound_routing.rs
  • crates/transport-nostr-peeler/src/lib.rs
  • crates/transport-nostr-peeler/src/peeler.rs
✅ Files skipped from review due to trivial changes (3)
  • crates/storage-sqlite/src/storage/test_support.rs
  • crates/cgka-engine/tests/hydration_quarantine.rs
  • crates/cgka-conformance-simulator/tests/openmls_replay_probe.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • crates/traits/src/lib.rs

Comment thread crates/marmot-account/src/runtime.rs
Comment thread crates/marmot-app/src/client/sync.rs
erskingardner added a commit that referenced this pull request Jul 2, 2026
…8/#689)

Package E of the #769 follow-ups: Quarantined{reason} becomes enforced
behavior, not just vocabulary.

- Ingest withhold guard: inbound for a quarantined group is retained
  (PeelDeferred, at the group's held epoch so it stays inside the
  convergence window) and classified Stale(Quarantined) — withheld, not
  discarded, and never processed live. New StaleReason::Quarantined,
  snapshot-locked, audit tag "quarantined".
- Live convergence exclusion: advance_convergence over a quarantined group
  is a no-op. Ordinary inbound and scheduled convergence can never silently
  re-activate a hold (spec group-state.md, "Quarantine").
- CgkaEngine::quarantine_group(reason): places the hold; an authoritative
  Left/Evicted is never downgraded to "withheld".
- CgkaEngine::resolve_group_quarantine: THE explicit recovery transition —
  the withhold guard stands down for exactly this group for exactly one
  deliberate convergence pass over the retained input (run decisively past
  the settlement quiescence window: resolution decides over a closed input
  set, it does not wait for stragglers). The ordinary apply seams decide the
  outcome: a retained removal commit resolves to Left/Evicted through the
  same code path as live traffic; Member is restored only when the live MLS
  state is active and the canonical roster still contains the local
  identity; anything else keeps the hold.
- Wiring: session + runtime passthroughs; the membership recovery probe now
  holds a group as Quarantined(PendingMembership) after 8 dry attempts
  (hours of backoff) instead of probing forever — the kind-451 inbox notice
  remains a live discovery path (it needs no group route), and resolution
  replays everything retained meanwhile.

Tests: quarantine withholds a live commit (epoch frozen, convergence
no-op) then resolve consumes it and restores Member at the advanced epoch;
quarantine never masks Evicted and resolving a non-quarantined group
reports the authoritative state. Full engine/app/account/session suites
green; workspace clippy clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
erskingardner added a commit that referenced this pull request Jul 2, 2026
…(#622)

Package F of the #769 follow-ups: membership becomes a set of retained
epoch intervals (a group may be left/removed and rejoined), and input from
outside every interval classifies as terminal PreMembership.

- traits: MembershipInterval { joined_at, ended_at } on the durable Group
  record (serde-defaulted; snapshot/rollback-coherent like participation);
  membership_intervals_contain fails OPEN on empty history — errors.md
  scopes PreMembership to groups with retained membership history, so
  legacy records never classify it. StaleReason::PreMembership,
  snapshot-locked, audit tag "pre_membership".
- Maintenance rides the participation transitions (one authority, one
  bookkeeper): create/join open an interval at the current/welcome epoch; a
  rejoin appends to the retained history instead of overwriting it; ending
  membership closes the open interval at the removing commit's SOURCE epoch
  — the last epoch this identity held keys for; post-removal epochs were
  never readable and stay outside. Quarantine holds touch no interval.
- Classification fires ONLY on the too-distant-in-the-past process arm —
  content this client can never read whose source epoch is readable.
  Deliberately NOT on the AlreadyAtEpoch arm: a commit older than our join
  is already represented by the joined state (welcome-before-commit) and
  keeps its established classification. Terminal: persisted Failed, never
  retried (unlike deferred MissingRetainedAnchor).

Tests: interval-contains unit matrix (empty fails open; join/gap/rejoin;
open interval extends forward); the remove→rejoin flow now asserts a
message sent inside the removed gap classifies Stale(PreMembership) while
welcome-before-commit still classifies AlreadyAtEpoch (fork_detection suite
kept green). Full engine + workspace crates green; clippy clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
erskingardner added a commit that referenced this pull request Jul 2, 2026
Package G of the #769 follow-ups: clients can now gate the composer and
group list on the local identity's participation.

- AppGroupMlsState gains a stable low-cardinality `participation` tag
  ("member" / "left" / "evicted" / "quarantined_pending_membership" /
  "quarantined_integrity_hold"), populated from the durable engine record on
  both the live read and the worker's command-ready snapshot (one builder
  covers both). Serde default keeps pre-field snapshots decoding as live
  members. Only "member" allows sending, per the public-surface rule in
  group-state.md — combined with participation() (pkg A), Ok(None) for "no
  such group", the API distinguishes every state the spec requires.
- group_participation_tag is now the single shared vocabulary in marmot-app
  (uniffi's event conversion re-exports it instead of duplicating the
  match), and AppGroupMlsStateFfi mirrors the field.

Test: tag stability matrix + legacy-snapshot default. marmot-app +
marmot-uniffi suites green; workspace clippy clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@erskingardner erskingardner changed the title Group lifecycle taxonomy: participation states + terminal outcomes (part of #727) Group lifecycle: participation taxonomy + eviction detection, recovery probe, removal notices, quarantine (#727) Jul 2, 2026
@erskingardner

Copy link
Copy Markdown
Member

Pushed the complete follow-up implementation as 8 bisectable commits (e592debb..2dbb83ae) — see the updated PR description for the package map. Every commit is individually green; full workspace suite (103 test binaries) + fast-ci pass at the head. The marmot-protocol/mdk#376 silent-eviction chain is closed end-to-end: cursor fix (root cause) → recovery probe → kind-451 notices (backstop) → participation state machine → subscription teardown → FFI surfacing. Ready for review/merge so the repo can reach zero open PRs.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
crates/cgka-engine/src/message_processor/ingest.rs (1)

118-120: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Propagate storage failures during participation checks.

Both checks currently fail open on get_group errors. A backend error can skip quarantine withholding or leave a Member record unrepaired while returning a stale outcome. Only NotFound should be ignored; other storage failures should propagate.

Proposed fix
-        let Ok(group) = self.storage.get_group(group_id) else {
-            return Ok(());
-        };
+        let group = match self.storage.get_group(group_id) {
+            Ok(group) => group,
+            Err(StorageError::NotFound) => return Ok(()),
+            Err(err) => return Err(EngineError::Storage(err)),
+        };
-            if !self.quarantine_resolutions.contains(&group_id)
-                && let Ok(group) = self.storage.get_group(&group_id)
-                && matches!(
-                    group.participation,
-                    cgka_traits::GroupParticipation::Quarantined { .. }
-                )
-            {
+            if !self.quarantine_resolutions.contains(&group_id) {
+                match self.storage.get_group(&group_id) {
+                    Ok(group)
+                        if matches!(
+                            group.participation,
+                            cgka_traits::GroupParticipation::Quarantined { .. }
+                        ) =>
+                    {
                         // PeelDeferred: the resolution pass replays exactly this state
                         // through `retry_deferred_peels`, re-entering ordinary ingest
                         // with the guard stood down.
                         self.persist_transport_message_for_existing_group(
                             msg,
                             &group_id,
                             group.epoch,
                             MessageState::PeelDeferred,
                         )?;
                         return Ok(IngestOutcome::Stale {
                             reason: StaleReason::Quarantined,
                         });
+                    }
+                    Ok(_) | Err(StorageError::NotFound) => {}
+                    Err(err) => return Err(EngineError::Storage(err)),
+                }
             }

Also applies to: 155-156

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/cgka-engine/src/message_processor/ingest.rs` around lines 118 - 120,
In the participation-check paths that call self.storage.get_group in ingest.rs,
stop treating every failure as a benign miss: only ignore the NotFound case and
propagate any other storage error instead of returning Ok(()). Update both
affected checks (including the later repeated get_group usage) so
backend/storage failures cannot incorrectly bypass quarantine withholding or
member repair while still preserving the existing behavior for genuinely missing
groups.
crates/cgka-engine/src/engine.rs (1)

949-984: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Preserve the empty-history sentinel on legacy records.

membership_intervals_contain([]) intentionally fails open, but the Member branch can turn a legacy quarantined record into [current_epoch..]; older retained inputs can then be misclassified as PreMembership. Only mutate intervals when retained history already exists, or synthesize a conservative legacy interval first.

One localized guard option
         if group.participation == participation {
             return Ok(());
         }
+        let had_retained_history = !group.membership_intervals.is_empty();
         group.participation = participation;
@@
             cgka_traits::GroupParticipation::Member => {
-                if !group
+                if had_retained_history
+                    && !group
                     .membership_intervals
                     .iter()
                     .any(|interval| interval.ended_at.is_none())
                 {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/cgka-engine/src/engine.rs` around lines 949 - 984, The
GroupParticipation handling in engine.rs is overwriting the empty-history
sentinel for legacy records by creating a new open MembershipInterval in the
Member branch, which can misclassify old quarantined inputs. Update the
participation transition logic around the membership_intervals update so it only
mutates intervals when retained history already exists, or first synthesizes a
conservative legacy interval before opening a new one. Keep the change localized
to the GroupParticipation::Member path and preserve the []-means-fail-open
behavior for membership_intervals_contain.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/cgka-engine/src/engine.rs`:
- Around line 1523-1528: The quarantined-group check in engine.rs currently uses
matches! on self.storage.get_group(...) and silently falls through on backend
errors, so update this control flow to propagate storage failures instead of
treating them like a non-quarantined group. In the code around the group
participation check in the engine logic, only let
cgka_traits::StorageError::NotFound continue to the normal unknown-group path,
and return/propagate any other storage error before live convergence proceeds.
- Around line 1609-1622: The recovery probe in engine.rs is masking OpenMLS and
storage read failures by using `.ok().flatten()` and `.unwrap_or(false)`, which
turns backend errors into a false “still quarantined” result. Update the probe
logic around `MlsGroup::load` and `storage.get_group` to preserve and return
those errors instead of collapsing them, so explicit recovery callers can
distinguish unresolved quarantine from operational failure. Keep the membership
and `is_active()` checks in the same `live_and_member` flow, but make the
overall result error-aware rather than defaulting to false on read failures.

In `@crates/traits/src/group.rs`:
- Around line 49-54: The `Membership` docs in `group.rs` describe `ended_at`
using the removing commit’s reached epoch, which conflicts with the inclusive
interval semantics used by the engine. Update the doc comment on `ended_at` to
state that it is the last epoch in the membership interval, i.e. the final
readable source epoch before removal (`group.epoch - 1`), and keep the wording
aligned with `PreMembership` and the `Membership`/`ended_at` semantics.

---

Outside diff comments:
In `@crates/cgka-engine/src/engine.rs`:
- Around line 949-984: The GroupParticipation handling in engine.rs is
overwriting the empty-history sentinel for legacy records by creating a new open
MembershipInterval in the Member branch, which can misclassify old quarantined
inputs. Update the participation transition logic around the
membership_intervals update so it only mutates intervals when retained history
already exists, or first synthesizes a conservative legacy interval before
opening a new one. Keep the change localized to the GroupParticipation::Member
path and preserve the []-means-fail-open behavior for
membership_intervals_contain.

In `@crates/cgka-engine/src/message_processor/ingest.rs`:
- Around line 118-120: In the participation-check paths that call
self.storage.get_group in ingest.rs, stop treating every failure as a benign
miss: only ignore the NotFound case and propagate any other storage error
instead of returning Ok(()). Update both affected checks (including the later
repeated get_group usage) so backend/storage failures cannot incorrectly bypass
quarantine withholding or member repair while still preserving the existing
behavior for genuinely missing groups.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: c1cb6243-9e6f-408c-bd33-9a5fe2a41a38

📥 Commits

Reviewing files that changed from the base of the PR and between 2184e5e and 2dbb83a.

⛔ Files ignored due to path filters (3)
  • crates/traits/tests/snapshots/snapshots__group.snap is excluded by !**/*.snap
  • crates/traits/tests/snapshots/snapshots__stale_pre_membership.snap is excluded by !**/*.snap
  • crates/traits/tests/snapshots/snapshots__stale_quarantined.snap is excluded by !**/*.snap
📒 Files selected for processing (22)
  • crates/cgka-conformance-simulator/tests/openmls_replay_probe.rs
  • crates/cgka-engine/src/audit_helpers.rs
  • crates/cgka-engine/src/engine.rs
  • crates/cgka-engine/src/group_lifecycle.rs
  • crates/cgka-engine/src/message_processor/ingest.rs
  • crates/cgka-engine/tests/hydration_quarantine.rs
  • crates/cgka-engine/tests/invite_leave.rs
  • crates/cgka-session/src/lib.rs
  • crates/marmot-account/src/runtime.rs
  • crates/marmot-account/tests/runtime.rs
  • crates/marmot-app/src/client/mod.rs
  • crates/marmot-app/src/client/sync.rs
  • crates/marmot-app/src/groups.rs
  • crates/marmot-app/src/lib.rs
  • crates/marmot-uniffi/src/conversions/event.rs
  • crates/marmot-uniffi/src/conversions/group.rs
  • crates/storage-sqlite/src/storage/test_support.rs
  • crates/traits/src/engine.rs
  • crates/traits/src/group.rs
  • crates/traits/src/ingest.rs
  • crates/traits/src/lib.rs
  • crates/traits/tests/snapshots.rs
🚧 Files skipped from review as they are similar to previous changes (14)
  • crates/cgka-conformance-simulator/tests/openmls_replay_probe.rs
  • crates/cgka-engine/tests/hydration_quarantine.rs
  • crates/cgka-engine/src/audit_helpers.rs
  • crates/marmot-app/src/client/mod.rs
  • crates/marmot-uniffi/src/conversions/event.rs
  • crates/traits/src/ingest.rs
  • crates/marmot-app/src/lib.rs
  • crates/storage-sqlite/src/storage/test_support.rs
  • crates/marmot-account/src/runtime.rs
  • crates/traits/src/engine.rs
  • crates/marmot-account/tests/runtime.rs
  • crates/cgka-session/src/lib.rs
  • crates/cgka-engine/src/group_lifecycle.rs
  • crates/marmot-app/src/client/sync.rs

Comment thread crates/cgka-engine/src/engine.rs Outdated
Comment on lines +1523 to +1528
if matches!(
self.storage.get_group(group_id).map(|g| g.participation),
Ok(cgka_traits::GroupParticipation::Quarantined { .. })
) {
return Ok(Vec::new());
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Propagate storage errors before live convergence.

matches!(self.storage.get_group(...), Ok(Quarantined { .. })) falls through on backend errors, so convergence can continue without proving the group is not withheld. Treat backend failures as errors; only NotFound should fall through to the normal unknown-group path.

Suggested control flow
-        if matches!(
-            self.storage.get_group(group_id).map(|g| g.participation),
-            Ok(cgka_traits::GroupParticipation::Quarantined { .. })
-        ) {
-            return Ok(Vec::new());
-        }
+        match self.storage.get_group(group_id).map(|g| g.participation) {
+            Ok(cgka_traits::GroupParticipation::Quarantined { .. }) => {
+                return Ok(Vec::new());
+            }
+            Ok(_) | Err(cgka_traits::StorageError::NotFound) => {}
+            Err(e) => return Err(EngineError::Backend(format!("get_group: {e:?}"))),
+        }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if matches!(
self.storage.get_group(group_id).map(|g| g.participation),
Ok(cgka_traits::GroupParticipation::Quarantined { .. })
) {
return Ok(Vec::new());
}
match self.storage.get_group(group_id).map(|g| g.participation) {
Ok(cgka_traits::GroupParticipation::Quarantined { .. }) => {
return Ok(Vec::new());
}
Ok(_) | Err(cgka_traits::StorageError::NotFound) => {}
Err(e) => return Err(EngineError::Backend(format!("get_group: {e:?}"))),
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/cgka-engine/src/engine.rs` around lines 1523 - 1528, The
quarantined-group check in engine.rs currently uses matches! on
self.storage.get_group(...) and silently falls through on backend errors, so
update this control flow to propagate storage failures instead of treating them
like a non-quarantined group. In the code around the group participation check
in the engine logic, only let cgka_traits::StorageError::NotFound continue to
the normal unknown-group path, and return/propagate any other storage error
before live convergence proceeds.

Comment thread crates/cgka-engine/src/engine.rs Outdated
Comment thread crates/traits/src/group.rs Outdated
@jgmontoya

Copy link
Copy Markdown
Contributor

One remaining concern: the membership backfill anchor uses the last consumed message’s recorded_at, which in this path is the Nostr event created_at / transport source timestamp, not local receipt time.

One thing: using Nostr created_at for relay since is correct, since relays filter on that. But it should be clamped against local time before deriving the backfill window. Otherwise a future-dated consumed event could make since = recorded_at - slack land after a missed removal commit, defeating the cursor-freeze recovery path.

Small related naming nit: TransportDelivery.received_at is currently set from relay_event.event.created_at, so the name reads like local receipt time but is actually source event time.

mubarakcoded and others added 11 commits July 2, 2026 14:46
…icted reason from the removal commit in path 2
…tices + recovery probe

Restructures the non-member-state design so participation transitions have
exactly one authority — the commit that removes the identity — and everything
else is a discovery mechanism for delivering that commit:

- spec(group-state): rewrite "Reaching a non-member state" around the single
  authority. The removed member can always process the removal commit (it is
  protected under its last member epoch), so the imperfect-information problem
  is delivery, not authentication. Discovery: (1) undecryptable traffic MUST
  trigger the active transport's missed-input recovery, bounded around the
  last consumed input, (2) removal notices carry/reference the commit with no
  authority of their own, (3) a bounded quarantine hold when probes stay dry.
  States the irreducible limit explicitly: detection is eventual, not
  immediate.
- spec(group-state): quarantine now carries a reason — pending_membership
  (exit: resolution via the commit) vs integrity_hold (exit: repair) — since
  the two holds have opposite recovery directions.
- spec(member-departure): new "Removal notices" section — committer SHOULD
  notify each removed member via its account inbox on bindings that have one;
  covers Left (SelfRemove confirmation) and Evicted symmetrically.
- Transport agnosticism enforced, not assumed: protocol-core names only the
  capability-neutral obligations (missed-input recovery, recipient inbox);
  transports/README.md checklist now requires every binding to state its
  recovery mechanism (or delivery guarantees making it unnecessary) and its
  removal-notice envelope (or that it carries none); quic.md explicitly
  answers both (carries no group evolution).
- spec(nostr): removal notice delivery as NIP-59 gift wrap -> kind 13 seal ->
  unsigned kind 451 rumor embedding the kind 445 removal-commit event
  (NIP-18 stringified-event convention), so no fetch round-trip or relay
  retention dependency; validation + metadata-privacy rules. Membership
  backfill probe (since = last consumed 445 minus slack) named as this
  binding's missed-input recovery.
- spec(errors): non-membership reached only by applying the removal commit;
  never asserted from an undecryptable message or unverified notice.
- spec(registries): allocate kind 451 (Marmot removal notice rumor).
- traits: GroupParticipation::Quarantined now carries QuarantineReason
  { PendingMembership, IntegrityHold }; snapshots updated (no external
  consumers yet, so still additive).

Verified: just fast-ci green; cargo test -p cgka-traits (90 tests) green;
protocol-core transport-leak grep clean (no kinds, relays, gift wraps, or
backfill mechanics outside the Nostr binding).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…emoval commits

Package A of the #769 follow-ups: participation transitions now have exactly
one authority — the applied removal commit — realized on BOTH apply paths.

- traits: Group gains a serde-defaulted `participation` field (legacy JSON
  records decode as Member; regression-tested). Living on the durable record
  means it survives live-OpenMLS teardown and rolls back with fork-recovery
  snapshots (a rolled-back removal also rolls back the transition).
  GroupEvent::ParticipationChanged surfaces transitions;
  CgkaEngine::participation() exposes Member/Left/Evicted/Quarantined vs
  None ("no such group") per the public-surface rule in group-state.md.
- engine direct seam (ingest.rs): self in the post-merge removed set →
  Left when the commit consumed our own SelfRemove, else Evicted.
- engine convergence seam: CommitStaged replay observations now carry
  self_remove_senders (resolved pre-merge, like the direct seam), so the
  replay path attributes MemberLeft to the leaver AND transitions self
  participation identically — the Left/Evicted distinction is no longer
  path-dependent (fixes the asymmetry documented in invite_leave.rs).
- rejoin: do_join_welcome resets participation to Member (fresh membership
  grant is the reinstatement path) and emits the transition when the prior
  record was non-member.
- inactive-group ingest arms (is_active()==false, UseAfterEviction): now
  invariant repairs — a record still claiming Member on an MLS-inactive
  group moves to Quarantined(IntegrityHold) instead of the signal being
  swallowed (the #722 arm); never fabricates a Left/Evicted reason.
- uniffi: ParticipationChanged FFI variant with stable low-cardinality
  participation tags.

Tests: invite_leave (Evicted via convergence apply; Left from consumed
SelfRemove; Member restored + event on re-add welcome; remaining member
unaffected), traits legacy-record decode + snapshots. Engine 18/18 test
binaries green; workspace tests green; clippy clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Package B of the #769 follow-ups: stop collapsing eviction-adjacent inbound
into the generic PeelFailed bucket.

- traits: StaleReason::Evicted — inbound for a group the local identity is
  no longer a member of; maps to the `evicted` category / `stale`
  disposition in spec/foundation/errors.md. Snapshot-locked. Classification
  never *drives* the participation transition (that stays with the applied
  removal commit, package A).
- engine: three arms now classify Evicted instead of PeelFailed/
  UnknownGroup:
  1. live MLS state inactive (merged removal) at ingest;
  2. the UseAfterEviction guard (path-1 aftermath);
  3. no live MLS state but the durable record says Left/Evicted — the
     tombstoned-group case after teardown, previously misreported as
     UnknownGroup (errors.md scopes unknown_group to "no state at all");
     persisted Failed (terminal) instead of Retryable.
- audit: stable "evicted" stale-reason string.

Test: readd_after_remove now asserts an evicted member ingesting the re-add
commit gets Stale(Evicted) — an evicted client cannot apply a later commit
and must not try — while the fresh Welcome still reinstates. PreMembership
classification is deferred to the membership-intervals package where it can
actually fire.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Package A2 of the #769 follow-ups — closes the relay-plane audit finding
that a removed group's subscriptions could persist forever.

Before: post-removal cleanup was gated on `state.groups.len() != before`
(sync.rs), which a convergence-applied removal never trips (the group record
is retained by design), and `refresh_group_routes` unconditionally re-added
every retained group's route — so an evicted group kept its relay
subscriptions and could even be silently re-added after a transient drop.
The adapter's sync_account_groups diff was already correct; the app simply
never handed it a reduced set.

Now, driven by the authoritative GroupEvent::ParticipationChanged (pkg A):
- Left/Evicted → self_membership flag (chat-list row stays renderable per
  the left-vs-removed distinction), route removed, transport groups synced
  immediately → adapter unsubscribes;
- Quarantined → route removed + synced (withheld from live processing; no
  membership flag write — quarantine asserts nothing about membership);
- Member (verified rejoin) → flag cleared, route re-added, synced.
- refresh_group_routes now filters groups whose durable self_membership is
  'removed', so no later refresh resurrects a dead group's subscription
  (survives restart via the existing account_groups projection).
- AppTransportRouting::remove_group + storage
  account_group_ids_with_removed_membership() query.

Tests: routing remove_group unit (targeted + idempotent), storage
removed-membership query roundtrip incl. rejoin flip-back. marmot-app +
storage-sqlite suites green; clippy clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…overy probe

Package C of the #769 follow-ups — the #722 root-cause fix.

The persisted transport cursor previously advanced after EVERY ingest,
including deliveries that failed outer decryption. Since the cursor becomes
the relay `since` filter, one post-removal (undecryptable) message pushed
the window past the missed removal commit's timestamp and no future
subscription ever fetched it: deterministic, permanent silent eviction.

Cursor fix (marmot-app): `outcome_advances_transport_cursor` — an
undecryptable delivery (Stale/PeelFailed) freezes the cursor at the last
input successfully consumed (the spec's recovery-window anchor); every
durably-accounted outcome (processed, buffered-for-convergence,
terminal-stale incl. Evicted) still advances it. Consumed input for a group
also resets that group's probe budget.

Membership recovery probe (spec group-state.md "Reaching a non-member
state", realized per transports/nostr.md "Membership backfill probe"):
- traits: TransportGroupBackfill + TransportAdapter::backfill_account_group.
- nostr adapter: re-issues the group's STORED route (caller endpoints
  ignored — a stale caller cannot point the probe at rogue relays) with the
  widened `since`, reusing the live subscription id so the relay replaces
  and replays instead of stacking duplicates; typed rejection for unknown
  groups.
- relay-plane wrapper: endpoint safety sanitization like group sync.
- marmot-account: backfill_transport_group resolves the routed subscription;
  routeless (withheld) groups are a no-op.
- marmot-app driver: undecryptable delivery for a known group triggers the
  probe with exponential backoff (60s doubling, 1h cap) anchored at the
  newest consumed app event minus a 300s slack; no consumed input → full
  re-fetch (dedup collapses overlap).

Tests: cursor-gating matrix (PeelFailed freezes; all else advances), probe
backoff schedule (base/double/cap, no overflow), runtime forwards the routed
subscription + since and no-ops without a route, adapter reissues the stored
route with probe window + rejects unknown groups + reuses the subscription
id. Workspace clippy clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ceive

Package D of the #769 follow-ups: the removal notice mechanism from
spec/transports/nostr.md ("Removal notice delivery") and
spec/protocol-core/member-departure.md ("Removal notices").

Wire shape (transport-nostr-peeler): NIP-59 gift wrap (1059 → 13 seal →
unsigned kind-451 rumor) addressed to the removed member. The rumor embeds
the exact published kind-445 removal-commit event as stringified JSON
(NIP-18 convention) with h/e cross-check tags — no fetch round-trip, no
relay-retention dependency, and the gift wrap leaks no group id to relays.
Peel side verifies the embedded event's id + signature and full kind-445
envelope validation before returning PeeledContent::RemovalNotice; a
tampered embed is rejected, so a forged notice can never inject bytes the
committer did not publish.

Authority stays with the commit: the engine's inbox seam re-injects the
embedded, transport-validated group message into ordinary do_ingest —
dedup, validation, and (when it really is our removal commit) the package-A
participation transition all apply as if it arrived on the group stream.
The notice itself changes nothing.

Committer send seam (marmot-account runtime): after this device's published
+ confirmed commit removes members (correlated by origin_commit_id against
the commits published in the same drain — only the committer holds the
published wire event, matching the spec's SHOULD), wrap per removed member
and publish to their cached inbox route. Every notice failure (no inbox
route, binding without notice support, publish error) is logged and
swallowed — a delivery aid must never fail the commit flow.

Plumbing: TransportPeeler::wrap_removal_notice and
CgkaEngine::wrap_removal_notice both default to Ok(None) ("this binding
does not carry removal notices") so mocks and future bindings stay
compiling and opt in explicitly; session passthrough; kind 451 constant.

Tests: peeler wrap/peel roundtrip (byte-identical embed, no relay-visible
group id) + non-445-embed rejection + tampered-embed rejection; runtime
committer test (commit publish then inbox-targeted notice carrying the
published commit); engine re-injection test (embedded message classifies
through ordinary ingest; non-group embeds rejected). Workspace clippy clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…8/#689)

Package E of the #769 follow-ups: Quarantined{reason} becomes enforced
behavior, not just vocabulary.

- Ingest withhold guard: inbound for a quarantined group is retained
  (PeelDeferred, at the group's held epoch so it stays inside the
  convergence window) and classified Stale(Quarantined) — withheld, not
  discarded, and never processed live. New StaleReason::Quarantined,
  snapshot-locked, audit tag "quarantined".
- Live convergence exclusion: advance_convergence over a quarantined group
  is a no-op. Ordinary inbound and scheduled convergence can never silently
  re-activate a hold (spec group-state.md, "Quarantine").
- CgkaEngine::quarantine_group(reason): places the hold; an authoritative
  Left/Evicted is never downgraded to "withheld".
- CgkaEngine::resolve_group_quarantine: THE explicit recovery transition —
  the withhold guard stands down for exactly this group for exactly one
  deliberate convergence pass over the retained input (run decisively past
  the settlement quiescence window: resolution decides over a closed input
  set, it does not wait for stragglers). The ordinary apply seams decide the
  outcome: a retained removal commit resolves to Left/Evicted through the
  same code path as live traffic; Member is restored only when the live MLS
  state is active and the canonical roster still contains the local
  identity; anything else keeps the hold.
- Wiring: session + runtime passthroughs; the membership recovery probe now
  holds a group as Quarantined(PendingMembership) after 8 dry attempts
  (hours of backoff) instead of probing forever — the kind-451 inbox notice
  remains a live discovery path (it needs no group route), and resolution
  replays everything retained meanwhile.

Tests: quarantine withholds a live commit (epoch frozen, convergence
no-op) then resolve consumes it and restores Member at the advanced epoch;
quarantine never masks Evicted and resolving a non-quarantined group
reports the authoritative state. Full engine/app/account/session suites
green; workspace clippy clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…(#622)

Package F of the #769 follow-ups: membership becomes a set of retained
epoch intervals (a group may be left/removed and rejoined), and input from
outside every interval classifies as terminal PreMembership.

- traits: MembershipInterval { joined_at, ended_at } on the durable Group
  record (serde-defaulted; snapshot/rollback-coherent like participation);
  membership_intervals_contain fails OPEN on empty history — errors.md
  scopes PreMembership to groups with retained membership history, so
  legacy records never classify it. StaleReason::PreMembership,
  snapshot-locked, audit tag "pre_membership".
- Maintenance rides the participation transitions (one authority, one
  bookkeeper): create/join open an interval at the current/welcome epoch; a
  rejoin appends to the retained history instead of overwriting it; ending
  membership closes the open interval at the removing commit's SOURCE epoch
  — the last epoch this identity held keys for; post-removal epochs were
  never readable and stay outside. Quarantine holds touch no interval.
- Classification fires ONLY on the too-distant-in-the-past process arm —
  content this client can never read whose source epoch is readable.
  Deliberately NOT on the AlreadyAtEpoch arm: a commit older than our join
  is already represented by the joined state (welcome-before-commit) and
  keeps its established classification. Terminal: persisted Failed, never
  retried (unlike deferred MissingRetainedAnchor).

Tests: interval-contains unit matrix (empty fails open; join/gap/rejoin;
open interval extends forward); the remove→rejoin flow now asserts a
message sent inside the removed gap classifies Stale(PreMembership) while
welcome-before-commit still classifies AlreadyAtEpoch (fork_detection suite
kept green). Full engine + workspace crates green; clippy clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Package G of the #769 follow-ups: clients can now gate the composer and
group list on the local identity's participation.

- AppGroupMlsState gains a stable low-cardinality `participation` tag
  ("member" / "left" / "evicted" / "quarantined_pending_membership" /
  "quarantined_integrity_hold"), populated from the durable engine record on
  both the live read and the worker's command-ready snapshot (one builder
  covers both). Serde default keeps pre-field snapshots decoding as live
  members. Only "member" allows sending, per the public-surface rule in
  group-state.md — combined with participation() (pkg A), Ok(None) for "no
  such group", the API distinguishes every state the spec requires.
- group_participation_tag is now the single shared vocabulary in marmot-app
  (uniffi's event conversion re-exports it instead of duplicating the
  match), and AppGroupMlsStateFfi mirrors the field.

Test: tag stability matrix + legacy-snapshot default. marmot-app +
marmot-uniffi suites green; workspace clippy clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@erskingardner
erskingardner force-pushed the feat/group-lifecycle-taxonomy branch from 2dbb83a to aefab2e Compare July 2, 2026 18:52
@erskingardner
erskingardner merged commit 0184225 into master Jul 2, 2026
21 checks passed
@erskingardner
erskingardner deleted the feat/group-lifecycle-taxonomy branch July 2, 2026 19:55
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.

3 participants