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

65 lines
3.3 KiB
Markdown

# 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<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
```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<CommunityMembership>;
```
## Data Model
```rust
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.