tidaldb/.sdlc/features/m9-community-profile-sync/spec.md

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_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

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.