Task — engineering-spec@1

"CRM activity feed — auto-log inbound email + outbound send + chat mention + calendar event to per-contact timeline"

draftTASK-CRM-002
module crm · class product · priority p0 · created 2026-05-17 · shipped null
depends on TASK-CRM-001, TASK-EMAIL-006 · blocks none

§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.

  1. MUST subscribe to events via event_subscribers.rs:
  1. MUST validate activity_kind against closed enum per DEC-1622.
  1. MUST dedup at dedup.rs::is_duplicate(activity) per DEC-1624 — same (contact_id, source_kind, source_id, kind) within 60s = skip + emit crm.activity_dedup_skipped.
  1. 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 ``
  1. MUST preserve source deep_link per DEC-1623 — each row has clickable origin (e.g. /email/threads/{id}, /chat/messages/{id}).
  1. MUST support manual note_added + call_logged via 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 ``
  1. MUST emit 3 memory audit kinds per DEC-1625. PII: summary text SHA-256 hashed per TASK-MEMORY-111.
  1. MUST thread trace_id from source event → subscriber → logger → audit.
  1. MUST NOT mutate prior activity per DEC-1621 — use correction_to column to chain.
  1. 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

  1. 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

FailureDetectionOutcomeRecovery
Subscriber lost eventretry via DLQsev-2 audit; manual replayinherent
Source event missing fieldsvalidateskip + sev-3data fix
Concurrent dedup raceUNIQUE on (src_kind,src_id,kind,occurred_at)second skippedinherent
Contact deleted post-eventFK NULL or skiplog to account onlyinherent
Deep_link invalidper-source validationlog with noteinherent
High-volume tenant (>10k acts/day)pagination + indexinherentinherent
Manual log spamrate-limit per user429inherent
Cross-tenant queryRLS0 rowsinherent
Backlog event during outagereplay queueinherentmanual run
Correction chain too deep (>10)sanity checksev-3; allowinherent

§11 — Implementation notes


End of TASK-CRM-002 spec.