# Spec: Leave & Stop-Forward (m9-leave-revocation) ## Problem A user opts into a community personalization layer — their engagement signals fan out to shared cohort aggregates. When they leave, two things must happen immediately: 1. **Stop forwarding** — new signals from this user no longer flow to community aggregates. 2. **Snapshot contribution boundary** — record the timestamp at which forwarding ceased, so downstream retroactive-purge (m9-retroactive-purge) and re-materialization (m9-purge-rematerialization) can identify exactly which historical contributions belong to this user and up to what point. Without this, a "leave" is silent — the system keeps accumulating the user's signal weight in community state indefinitely, violating the principle that personalization is user-owned and revocable (see VISION.md §Design Principles). ## What This Feature Delivers A single, explicit API call — `db.leave_community_layer(user_id)` — that: 1. Atomically sets a durable **community membership flag** for the user to `Left`, recording the leave timestamp as nanoseconds since Unix epoch. 2. Gates the `try_cohort_attribution` and `try_community_forwarding` paths in `signal_with_context` so signals from users with status `Left` skip cohort fan-out. 3. Exposes a **re-enrollment path** — `db.rejoin_community_layer(user_id)` — that clears the gate and allows signal forwarding to resume. The previous leave snapshot is preserved in history so future purge requests can reference it. 4. Provides `db.community_layer_status(user_id)` to query current status. ## Scope **In scope:** - `CommunityMembership` struct: `{ user_id, status: Active | Left, left_at_ns: Option }` - Durable persistence via `Tag::CommunityLeave = 0x11` in the users storage backend - Gate in `try_cohort_attribution` AND `try_community_forwarding`: check membership status before fan-out - `db.leave_community_layer(user_id)` — sets status to `Left`, records timestamp - `db.rejoin_community_layer(user_id)` — sets status to `Active`, preserves leave history - `db.community_layer_status(user_id)` — returns current status - In-memory `DashMap` cache for O(1) gate check on the hot signal path - Restore membership map from durable storage on `TidalDb::from_parts` **Out of scope:** - Retroactive purge of historical contributions (m9-retroactive-purge) - Re-materialization of community aggregates (m9-purge-rematerialization) - Community profile sync (m9-community-profile-sync) ## API ```rust pub fn leave_community_layer(&self, user_id: u64) -> crate::Result<()>; pub fn rejoin_community_layer(&self, user_id: u64) -> crate::Result<()>; pub fn community_layer_status(&self, user_id: u64) -> crate::Result; ``` ## Data Model ```rust pub enum MembershipStatus { Active, Left } pub struct CommunityMembership { pub user_id: u64, pub status: MembershipStatus, pub left_at_ns: Option, } ``` Serialization: `[status: 1 byte][left_at_ns: 8 bytes LE]` — fixed 9-byte value. Storage key: `encode_key(EntityId::new(user_id), Tag::CommunityLeave, b"")` in users storage. ## Performance - Gate check: `DashMap::get(user_id)` — O(1), lock-free. Absent entry = Active (default). - leave/rejoin writes: one storage `put` + one DashMap `insert`. Not on the hot path.