"CRM activity feed — auto-log inbound email + outbound send + chat mention + calendar event to per-contact timeline"
§1 — Description (BCP-14 normative)
The CRM service MUST ship activity feed at services/crm/src/activity/ subscribing to EMAIL/CHAT/calendar/deal events, deduplicating cross-source, logging per-contact + per-account, 3 memory audit kinds.
- MUST subscribe to events via
event_subscribers.rs:
email.inbound_processed(TASK-EMAIL-006 fires after link) →activity_kind=email_inboundemail.message_sent(TASK-EMAIL-009) →email_outboundchat.mention_received(TASK-CHAT-005) →chat_mentioncalendar.meeting_scheduled→calendar_meetingcrm.deal_stage_changed→deal_stage_change
- MUST validate
activity_kindagainst closed enum per DEC-1622.
- MUST dedup at
dedup.rs::is_duplicate(activity)per DEC-1624 — same(contact_id, source_kind, source_id, kind)within 60s = skip + emitcrm.activity_dedup_skipped.
- MUST define table at migration
0002: ``sql CREATE TABLE crm_activities ( activity_id UUID PRIMARY KEY, tenant_id UUID NOT NULL, contact_id UUID, account_id UUID, deal_id UUID, kind TEXT NOT NULL CHECK (kind IN ('email_inbound','email_outbound','chat_mention','calendar_meeting','deal_stage_change','note_added','call_logged')), summary TEXT NOT NULL, source_kind TEXT NOT NULL, source_id UUID, deep_link TEXT NOT NULL, actor_id UUID, occurred_at TIMESTAMPTZ NOT NULL, trace_id CHAR(32), correction_to UUID, -- points to prior activity_id if this is a correction created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX crm_activities_contact_time_idx ON crm_activities(tenant_id, contact_id, occurred_at DESC) WHERE contact_id IS NOT NULL; CREATE INDEX crm_activities_account_time_idx ON crm_activities(tenant_id, account_id, occurred_at DESC) WHERE account_id IS NOT NULL; CREATE INDEX crm_activities_dedup_idx ON crm_activities(tenant_id, source_kind, source_id, kind, occurred_at); ALTER TABLE crm_activities ENABLE ROW LEVEL SECURITY; CREATE POLICY activities_rls ON crm_activities USING (tenant_id = current_setting('auth.tenant_id')::uuid) WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid); REVOKE UPDATE, DELETE ON crm_activities FROM cyberos_app; -- No UPDATE grant — append-only per DEC-1621``
- MUST preserve source deep_link per DEC-1623 — each row has clickable origin (e.g.
/email/threads/{id},/chat/messages/{id}).
- MUST support manual
note_added+call_loggedvia POST endpoint: ``text POST /v1/crm/activities (CRO/AM via TASK-AUTH-101) GET /v1/crm/contacts/{id}/activities?limit=50 (paginated, desc time) GET /v1/crm/accounts/{id}/activities?limit=50``
- MUST emit 3 memory audit kinds per DEC-1625. PII: summary text SHA-256 hashed per TASK-MEMORY-111.
- MUST thread trace_id from source event → subscriber → logger → audit.
- MUST NOT mutate prior activity per DEC-1621 — use
correction_tocolumn to chain.
- MUST NOT log to contact that doesn't exist — return early; sev-3 audit.
§2 — Why this design
Why event subscribers (DEC-1620)? Pull-based scanning misses real-time view; subscribers fire as events happen.
Why dedup (DEC-1624)? Multi-source ingestion (e.g. inbound email + convert-to-issue + reply) produces 3 events for one logical interaction; CRM would be noisy.
Why append-only (DEC-1621)? Audit lineage requires unmutable history; corrections via new row preserve trail.
Why deep_link (DEC-1623)? CRO clicks to see original; without link, activity feed is just trivia.
§3 — API contract
Sample activity:
{
"activity_id": "uuid",
"contact_id": "uuid",
"account_id": "uuid",
"kind": "email_inbound",
"summary": "Re: Q3 pricing question — replied with proposal",
"source_kind": "email_thread",
"source_id": "uuid",
"deep_link": "/email/threads/abc-123",
"actor_id": "uuid-of-receiver",
"occurred_at": "2026-05-17T10:00:00Z"
}
§4 — Acceptance criteria
- Email inbound logged. 2. Email outbound logged. 3. Chat mention logged. 4. Calendar meeting logged. 5. Deal stage change logged. 6. Manual note/call POST works. 7. Closed enum 7 + cardinality test. 8. Dedup skip within 60s window. 9. 3 memory audit kinds emitted. 10. PII scrubbed (summary SHA256). 11. RLS denies cross-tenant. 12. Trace_id preserved. 13. Deep_link present + clickable. 14. Pagination (desc time). 15. Per-contact filter. 16. Per-account filter (rolls up contacts). 17. Correction_to chains. 18. Append-only (REVOKE UPDATE). 19. Activity to nonexistent contact rejected. 20. Source_kind enum (open: email_thread/chat/calendar/manual/etc.).
§5 — Verification
#[tokio::test]
async fn email_inbound_creates_activity() {
let ctx = TestContext::with_tracked_domain_and_contact().await;
ctx.receive_inbound("jane@acme.com", "Q3 question").await;
tokio::time::sleep(Duration::from_secs(1)).await; // wait for subscriber
let acts = ctx.fetch_contact_activities(ctx.contact_id).await;
assert!(acts.iter().any(|a| a.kind == "email_inbound"));
}
#[tokio::test]
async fn dedup_within_60s() {
let ctx = TestContext::new().await;
let src = Uuid::new_v4();
ctx.log_activity_raw(ctx.contact_id, "email_inbound", src).await;
ctx.log_activity_raw(ctx.contact_id, "email_inbound", src).await;
let acts = ctx.fetch_contact_activities(ctx.contact_id).await;
assert_eq!(acts.iter().filter(|a| a.source_id == Some(src)).count(), 1);
}
#[tokio::test]
async fn append_only_no_update() {
let ctx = TestContext::with_activity().await;
let result = ctx.try_update_activity(ctx.activity_id, "tampered").await;
assert!(result.is_err());
}
// 5.4..5.10
§7 — Dependencies
Upstream: TASK-CRM-001, TASK-EMAIL-006. Cross-module: TASK-EMAIL-009 (outbound event), TASK-CHAT-005 (mention), TASK-AUTH-101 (manual logger), TASK-MEMORY-111 (PII).
§10 — Failure modes
| Failure | Detection | Outcome | Recovery |
|---|---|---|---|
| Subscriber lost event | retry via DLQ | sev-2 audit; manual replay | inherent |
| Source event missing fields | validate | skip + sev-3 | data fix |
| Concurrent dedup race | UNIQUE on (src_kind,src_id,kind,occurred_at) | second skipped | inherent |
| Contact deleted post-event | FK NULL or skip | log to account only | inherent |
| Deep_link invalid | per-source validation | log with note | inherent |
| High-volume tenant (>10k acts/day) | pagination + index | inherent | inherent |
| Manual log spam | rate-limit per user | 429 | inherent |
| Cross-tenant query | RLS | 0 rows | inherent |
| Backlog event during outage | replay queue | inherent | manual run |
| Correction chain too deep (>10) | sanity check | sev-3; allow | inherent |
§11 — Implementation notes
- §11.1 Subscribers via internal event bus or polling task-memory audit log; choose per-deployment.
- §11.2 Dedup window 60s — short enough to catch races, long enough to allow distinct re-sends.
- §11.3 Summary auto-generated: kind-specific template (
"Email from {sender}: {subject_first_60_chars}"). - §11.4 memory audit body: kind, contact_id, source ids; summary SHA256.
- §11.5 UI timeline shows kind icons + deep_link + relative time.
End of TASK-CRM-002 spec.