tidaldb/.sdlc/features/m9-leave-revocation/spec.md

3.3 KiB

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 pathdb.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<u64> }
  • 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<u64, CommunityMembership> 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

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<CommunityMembership>;

Data Model

pub enum MembershipStatus { Active, Left }
pub struct CommunityMembership {
    pub user_id: u64,
    pub status: MembershipStatus,
    pub left_at_ns: Option<u64>,
}

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.