# 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::"`) 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>) ``` ### 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>`) 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>; pub fn is_community_member(&self, user_id: u64, community_name: &str) -> crate::Result; ``` --- ## 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.