5.0 KiB
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_contextalways 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
- Users can opt into a named community layer — a named, shared signal aggregate.
- 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). - The local WAL remains the primary durability boundary — community forwarding is best-effort and does not block the primary write path.
- Community membership is persisted to durable storage so it survives restarts.
- Community membership can be queried (read membership status).
- The API is minimal and correct — no over-engineering. Community aggregates reuse the existing
CohortSignalLedgerinfrastructure (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
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
join_community(user_id, "jazz")succeeds and persists durably.- After restart,
get_community_memberships(user_id)returns["jazz"]. signal_with_contextfor a member routes signal to both global and community aggregate.signal_with_contextfor a non-member does NOT write to any community aggregate.is_community_memberreturnstruefor a member andfalsefor a non-member.join_communitywith an empty name returnsTidalError::InvalidInput.join_communitywith a 65-char name returnsTidalError::InvalidInput.- Multiple community memberships: signals fan out to all communities.
- Community aggregate reads via
cohort_ledger().read_windowed_count("community::jazz", ...)reflect forwarded signals. - Forwarding failure does not fail the primary signal write path.