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

104 lines
3.4 KiB
Markdown

# Design: Community Profile Sync
## Overview
Community Profile Sync adds opt-in routing of user engagement signals to named community personalization aggregates. When a user joins a community, their future `signal_with_context` writes are forwarded to the community's aggregate — a named partition of the existing `CohortSignalLedger` — in addition to the global ledger.
The design reuses existing infrastructure wherever possible:
- **Aggregate storage**: `CohortSignalLedger` (keyed by `"community::<name>"`)
- **Persistence layer**: `users_engine` fjall partition (new `Tag::CommunityMembership = 0x0E`)
- **Forwarding hook**: `try_community_forwarding()` called from `signal_with_context`
---
## Storage Layout
### Membership Records
```
Key: encode_key(EntityId::new(user_id), Tag::CommunityMembership, community_name.as_bytes())
Value: member_since_ns as [u8; 8] (little-endian u64)
```
**Tag assignment:** `Tag::CommunityMembership = 0x0E`
### Community Signal Aggregates
Aggregates are stored in the existing `CohortSignalLedger` with the key prefix `"community::<name>"`:
```
CohortSignalLedger key: ("community::jazz", entity_id, signal_type_id)
```
---
## New Components
### `Tag::CommunityMembership = 0x0E`
Added to `tidal/src/storage/keys.rs`.
### `CommunityMembershipIndex`
New module: `tidal/src/entities/community.rs`
```rust
pub struct CommunityMembershipIndex {
memberships: DashMap<u64, Vec<String>>,
}
impl CommunityMembershipIndex {
pub fn add(&self, user_id: u64, community: &str);
pub fn get(&self, user_id: u64) -> Vec<String>;
pub fn contains(&self, user_id: u64, community: &str) -> bool;
pub fn entry_count(&self) -> usize;
}
```
### `db/community.rs`
New module with public methods on `TidalDb`: `join_community`, `get_community_memberships`, `is_community_member`.
### `try_community_forwarding`
Private method in `db/signals.rs`, called from `signal_with_context` after `try_cohort_attribution`:
```rust
fn try_community_forwarding(&self, signal_type, entity_id, weight, timestamp, user_id) {
let memberships = self.community_membership.get(user_id);
if memberships.is_empty() { return; }
let Ok(type_id) = self.cohort_ledger.resolve_signal_type(signal_type) else { return; };
let ts_ns = timestamp.as_nanos();
for name in &memberships {
self.cohort_ledger.record(&format!("community::{name}"), entity_id, type_id, weight, ts_ns);
}
}
```
### `rebuild_community_memberships`
Added to `tidal/src/db/state_rebuild.rs`, called during open:
- Scans `Tag::CommunityMembership` keys from users_engine
- Populates `CommunityMembershipIndex`
---
## Changes by File
| File | Change |
|------|--------|
| `tidal/src/storage/keys.rs` | Add `Tag::CommunityMembership = 0x0E`; update `from_byte`, tests |
| `tidal/src/entities/community.rs` | New: `CommunityMembershipIndex` |
| `tidal/src/entities/mod.rs` | `pub mod community;` + re-export |
| `tidal/src/db/mod.rs` | Add `community_membership` field; wire in constructors |
| `tidal/src/db/community.rs` | New: `join_community`, `get_community_memberships`, `is_community_member` |
| `tidal/src/db/signals.rs` | Call `try_community_forwarding` in `signal_with_context` |
| `tidal/src/db/state_rebuild.rs` | `rebuild_community_memberships` called during open |
| `tidal/tests/m9_community_sync.rs` | New: 10 integration tests |
---
## Implementation Status
COMPLETE — All components implemented. 1299 lib tests pass. 10 integration tests pass. fmt/clippy clean.