104 lines
5.0 KiB
Markdown
104 lines
5.0 KiB
Markdown
# Spec: Community Profile Sync
|
|
|
|
## Feature
|
|
|
|
**Slug:** m9-community-profile-sync
|
|
**Title:** Community Profile Sync
|
|
**Description:** Opt-in sharing from local embedded profiles to community personalization layers — events flow to shared aggregates while local WAL remains primary.
|
|
|
|
---
|
|
|
|
## Problem
|
|
|
|
tidalDB's personalization model has three distinct scopes per the VISION.md: global (user-owned), community (shared overlay), and session/agent (short-lived). The community layer — where users opt-in to contribute their engagement signals to a shared community personalization pool — is currently unimplemented.
|
|
|
|
Without this feature:
|
|
- Community-scoped trending, quality signals, and collaborative personalization cannot be powered by real user engagement.
|
|
- Applications have no sanctioned path to collect consent-gated signals into a shared aggregate.
|
|
- The signal fan-out in `signal_with_context` always routes to a single user's local profile; there is no opt-in gate that routes events to a separate community layer.
|
|
|
|
This feature implements the opt-in join path and the event forwarding routing that makes community aggregates live.
|
|
|
|
---
|
|
|
|
## Goals
|
|
|
|
1. Users can opt into a named community layer — a named, shared signal aggregate.
|
|
2. When a user with community membership records a signal via `signal_with_context`, the event is forwarded to the community's signal aggregate (in addition to the local ledger).
|
|
3. The local WAL remains the primary durability boundary — community forwarding is best-effort and does not block the primary write path.
|
|
4. Community membership is persisted to durable storage so it survives restarts.
|
|
5. Community membership can be queried (read membership status).
|
|
6. The API is minimal and correct — no over-engineering. Community aggregates reuse the existing `CohortSignalLedger` infrastructure (keyed by community name) rather than introducing a new aggregate type.
|
|
|
|
---
|
|
|
|
## Non-Goals
|
|
|
|
- Leaving or revoking community membership (handled by m9-leave-revocation).
|
|
- Retroactive purge of contributed signals (handled by m9-retroactive-purge).
|
|
- Cross-node, distributed community aggregates. This is single-node-first.
|
|
- Privacy controls or consent UI — this is the engine primitive; the application handles consent UX.
|
|
- Community-scoped ranking profiles (can be built on top via cohort-scoped trending once this lands).
|
|
|
|
---
|
|
|
|
## Core Design
|
|
|
|
### Community as a Named Cohort Overlay
|
|
|
|
A "community" in this feature is a named, shared signal aggregate. It differs from a Cohort (which is a live predicate over user metadata) in that community membership is explicit opt-in rather than computed. However, the underlying signal aggregate is structurally identical to `CohortSignalLedger` entries.
|
|
|
|
**Design decision:** Reuse `CohortSignalLedger` with a reserved prefix (`"community::<name>"`) to store community-scoped signal aggregates. This avoids new storage structures and leverages existing cohort-scoped trending queries.
|
|
|
|
### Community Membership Store
|
|
|
|
Membership records are stored durably in the users storage partition.
|
|
|
|
```
|
|
Tag::CommunityMembership = 0x0E
|
|
Key: encode_key(EntityId::new(user_id), Tag::CommunityMembership, community_name.as_bytes())
|
|
Value: member_since_ns as u64 little-endian (8 bytes)
|
|
```
|
|
|
|
### Opt-In Flow
|
|
|
|
```
|
|
db.join_community(user_id, community_name) -> Result<()>
|
|
→ validate community name (non-empty, valid UTF-8, <= 64 chars)
|
|
→ write durable membership record to users_engine
|
|
→ add to in-memory membership index (DashMap<u64, Vec<String>>)
|
|
```
|
|
|
|
### Signal Forwarding
|
|
|
|
When `signal_with_context` is called with a `for_user` that has community memberships, events are forwarded to each community's aggregate via `try_community_forwarding`. This is best-effort and never fails the primary write path.
|
|
|
|
### In-Memory Membership Index
|
|
|
|
A `CommunityMembershipIndex` (newtype over `DashMap<u64, Vec<String>>`) is added to `TidalDb`. It is populated on startup by scanning users_engine for all `Tag::CommunityMembership` keys.
|
|
|
|
---
|
|
|
|
## API
|
|
|
|
```rust
|
|
pub fn join_community(&self, user_id: u64, community_name: &str) -> crate::Result<()>;
|
|
pub fn get_community_memberships(&self, user_id: u64) -> crate::Result<Vec<String>>;
|
|
pub fn is_community_member(&self, user_id: u64, community_name: &str) -> crate::Result<bool>;
|
|
```
|
|
|
|
---
|
|
|
|
## Acceptance Criteria
|
|
|
|
1. `join_community(user_id, "jazz")` succeeds and persists durably.
|
|
2. After restart, `get_community_memberships(user_id)` returns `["jazz"]`.
|
|
3. `signal_with_context` for a member routes signal to both global and community aggregate.
|
|
4. `signal_with_context` for a non-member does NOT write to any community aggregate.
|
|
5. `is_community_member` returns `true` for a member and `false` for a non-member.
|
|
6. `join_community` with an empty name returns `TidalError::InvalidInput`.
|
|
7. `join_community` with a 65-char name returns `TidalError::InvalidInput`.
|
|
8. Multiple community memberships: signals fan out to all communities.
|
|
9. Community aggregate reads via `cohort_ledger().read_windowed_count("community::jazz", ...)` reflect forwarded signals.
|
|
10. Forwarding failure does not fail the primary signal write path.
|