Skip to content

feat(activity): activity_events table + consumer - #5495

Merged
synoet merged 6 commits into
mainfrom
synoet/activity-foundation
Aug 10, 2026
Merged

feat(activity): activity_events table + consumer#5495
synoet merged 6 commits into
mainfrom
synoet/activity-foundation

Conversation

@synoet

@synoet synoet commented Aug 7, 2026

Copy link
Copy Markdown
Contributor
  • Adds an activity table to track user activity on entities
  • Setup a activity kafka consumer
  • activity crate defines a set of common activity events + a trait for other domains to implement their own activity
  • nothing consumes the table as of yet.

@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: dd4a187a-ff1f-4072-9f2d-80883541e80f

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Summary by CodeRabbit

  • New Features
    • Added activity tracking across calls, channels, chats, documents, email, projects, and properties.
    • Recorded creations, edits, messages, participant changes, deletions, and other supported actions with actor and timestamp details.
    • Added durable activity storage with duplicate protection, entity-based cleanup, and event-driven processing.
    • Added database support for activity history and automated processing of relevant events.
  • Tests
    • Added comprehensive coverage for activity classification, persistence, attribution, timestamps, and cleanup.

Walkthrough

The pull request adds a new activity Rust crate with shared activity models, ingestion contracts, Kafka consumption, and PostgreSQL persistence. It creates the activity_events migration and repository adapter. Call, channel, chat, document, email, project, and property domains map topic events to activity inserts, purges, or ignores. The document storage service registers event topics, dispatches ingestion, and supervises the consumer. Workspace dependency metadata is updated.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title uses the conventional commits format, stays under 72 characters, and describes the activity table and consumer changes.
Description check ✅ Passed The description accurately summarizes the activity table, Kafka consumer, activity crate, and current shadow-mode usage.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown

@synoet
synoet force-pushed the synoet/activity-foundation branch from 5592c93 to 83bd0db Compare August 7, 2026 20:36
One append-only table of activities: a principal did something to an
entity at a time. Activity ids are uuidv5(source event id, ordinal) so
at-least-once redelivery is absorbed by ON CONFLICT (id) DO NOTHING; one
broker event may yield several activities (participant adds, call started).

crates/activity (hexagonal, per soup_realtime) consumes macro.documents,
.channels, .chats, .projects, .email, .properties, and .calls under group
activity-materializer, mapping attributed mutations to activities:
created / edited / deleted / messaged / sent / property_changed /
participant_added / participant_removed / call_started. Unattributable
mutations (no actor) are dropped; provider-initiated email changes are
dropped; assistant chat messages are dropped (only the user's own prompts
are their activity). Hard deletes purge the entity's activities, including
project purge cascades.

Delegation resolves at ingestion: subject = on_behalf_of ?? actor (channel
messages carry triggered_by for agent-sent messages). The consumer runs in
shadow inside document_storage_service — activities accumulate, nothing
reads them yet.
@synoet
synoet force-pushed the synoet/activity-foundation branch from 83bd0db to e1b8e61 Compare August 7, 2026 20:48
synoet added 2 commits August 7, 2026 16:54
PropertyChange / ParticipantChange / CallStart are defined once and shared:
the write codec serializes them and the future read codec deserializes the
same definitions, instead of hand-mirroring field shapes in json! blocks.
CommonAction::PropertyChanged reuses the same payload struct.
The tag strings were hand-typed duplicates of the variant names. strum's
IntoStaticStr (snake_case) derives them, matching the model-entity
convention. Renaming a variant now silently renames the stored tag — the
pinned full-vocabulary codec test is what makes that a loud failure
instead of a quiet storage migration.
@synoet
synoet marked this pull request as ready for review August 10, 2026 13:03
@synoet
synoet requested a review from a team as a code owner August 10, 2026 13:03

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (3)
crates/projects/src/domain/activity/test.rs (1)

52-74: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Cover the chat branch of the purge cascade.

The test sets purged_chat_ids: vec![]. The mapper maps purged_chat_ids to EntityType::Chat entries at crates/projects/src/domain/activity.rs lines 87-91. That branch stays unexercised, so the test name "the whole cascade" overstates the coverage. A regression that drops the chat entries would still pass.

♻️ Proposed fixture and assertion update
             purged_project_ids: vec!["proj-2".to_string()],
             purged_document_ids: vec!["doc-1".to_string()],
-            purged_chat_ids: vec![],
+            purged_chat_ids: vec!["chat-1".to_string()],
         },
     ));
 
     assert_eq!(
         event.event.ingest(event.event_id),
         Ingest::Purge(vec![
             (EntityType::Project, "proj-1".to_string()),
             (EntityType::Project, "proj-2".to_string()),
             (EntityType::Document, "doc-1".to_string()),
+            (EntityType::Chat, "chat-1".to_string()),
         ])
     );
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/projects/src/domain/activity/test.rs` around lines 52 - 74, Update
permanent_delete_purges_the_whole_cascade to include at least one chat ID in
purged_chat_ids and add the corresponding (EntityType::Chat, chat ID) entry to
the expected Ingest::Purge list, covering the chat branch alongside the existing
project and document entries.
crates/projects/src/domain/activity.rs (1)

63-71: 🗄️ Data Integrity & Integration | 🔵 Trivial | 💤 Low value

Consider a distinct action for Restored.

The Restored arm maps to CommonAction::Edited. A restore and an edit then become indistinguishable in the durable table. The activity table is append-only, so a later split of the two actions requires a backfill of existing rows. If the shared vocabulary should stay small for now, record the decision in the comment above this arm.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/projects/src/domain/activity.rs` around lines 63 - 71, Update the
ProjectTopicEvent::Restored arm to use a distinct CommonAction variant for
restore events instead of CommonAction::Edited, ensuring restored and edited
activities remain distinguishable in the append-only activity table; if no
distinct variant is currently appropriate, document that decision in a comment
directly above the arm.
crates/chat/src/domain/activity/test.rs (1)

40-53: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Assert the activity count before indexing.

Lines 43 and 53 index activities[0] directly. The let ... else only proves the Insert variant, not a non-empty vector. If the mapper returns an empty vector, the test fails with an index panic instead of a clear assertion message. crates/projects/src/domain/activity/test.rs line 33 already asserts activities.len().

♻️ Proposed test tightening
     let Ingest::Insert(activities) = created.event.ingest(created.event_id) else {
         panic!("expected activities");
     };
+    assert_eq!(activities.len(), 1);
     assert_eq!(activities[0].action, Action::Created);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/chat/src/domain/activity/test.rs` around lines 40 - 53, Update the
activity assertions in the test around the created and sent events to assert
activities.len() before indexing activities[0], matching the established pattern
in the projects activity test. Preserve the existing action and entity_type
assertions after confirming the expected activity count.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/call/src/domain/activity.rs`:
- Around line 53-83: Update the CallTopicEvent::RecordDeleted handling to purge
the related channel activity using the deletion key for the call, in addition to
purging the Call entity activity. Limit the channel purge to activities
associated with that specific call and do not purge the entire channel; reuse
the existing deletion-key mechanism and Ingest::Purge flow.

In `@crates/email/src/domain/activity.rs`:
- Around line 78-91: Update the EmailTopicEvent::MessageSent arm to emit the
activity only when both m.actor is present and m.origin is
EmailEventOrigin::UserAction; return Ingest::Ignore for provider-synchronized
sends even when an actor exists. Add coverage for a ProviderSync MessageSent
carrying an actor.

---

Nitpick comments:
In `@crates/chat/src/domain/activity/test.rs`:
- Around line 40-53: Update the activity assertions in the test around the
created and sent events to assert activities.len() before indexing
activities[0], matching the established pattern in the projects activity test.
Preserve the existing action and entity_type assertions after confirming the
expected activity count.

In `@crates/projects/src/domain/activity.rs`:
- Around line 63-71: Update the ProjectTopicEvent::Restored arm to use a
distinct CommonAction variant for restore events instead of
CommonAction::Edited, ensuring restored and edited activities remain
distinguishable in the append-only activity table; if no distinct variant is
currently appropriate, document that decision in a comment directly above the
arm.

In `@crates/projects/src/domain/activity/test.rs`:
- Around line 52-74: Update permanent_delete_purges_the_whole_cascade to include
at least one chat ID in purged_chat_ids and add the corresponding
(EntityType::Chat, chat ID) entry to the expected Ingest::Purge list, covering
the chat branch alongside the existing project and document entries.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 227db723-286b-410e-81b4-6b484b815699

📥 Commits

Reviewing files that changed from the base of the PR and between 8480d35 and 354eedb.

⛔ Files ignored due to path filters (3)
  • .sqlx/query-29b8ca0e672d412aeeaf19c3f633fb10fb5b41ac6a0ff074eefcc71123353872.json is excluded by !**/.sqlx/**
  • .sqlx/query-6d61d631a10b803cb269fa6992ef84adbf8b0ad0940ab61b8a649f74584d8b0e.json is excluded by !**/.sqlx/**
  • Cargo.lock is excluded by !**/*.lock, !**/Cargo.lock
📒 Files selected for processing (46)
  • .github/workspace-dep-closures.json
  • Cargo.toml
  • crates/activity/Cargo.toml
  • crates/activity/src/domain/mod.rs
  • crates/activity/src/domain/models.rs
  • crates/activity/src/domain/models/test.rs
  • crates/activity/src/domain/ports.rs
  • crates/activity/src/inbound/kafka_consumer.rs
  • crates/activity/src/inbound/mod.rs
  • crates/activity/src/lib.rs
  • crates/activity/src/outbound/mod.rs
  • crates/activity/src/outbound/pg_activity_repo.rs
  • crates/activity/src/outbound/pg_activity_repo/test.rs
  • crates/call/Cargo.toml
  • crates/call/src/domain/activity.rs
  • crates/call/src/domain/activity/test.rs
  • crates/call/src/domain/mod.rs
  • crates/channels/Cargo.toml
  • crates/channels/src/domain/activity.rs
  • crates/channels/src/domain/activity/test.rs
  • crates/channels/src/domain/mod.rs
  • crates/chat/Cargo.toml
  • crates/chat/src/domain.rs
  • crates/chat/src/domain/activity.rs
  • crates/chat/src/domain/activity/test.rs
  • crates/documents/Cargo.toml
  • crates/documents/src/domain.rs
  • crates/documents/src/domain/activity.rs
  • crates/documents/src/domain/activity/test.rs
  • crates/email/Cargo.toml
  • crates/email/src/domain.rs
  • crates/email/src/domain/activity.rs
  • crates/email/src/domain/activity/test.rs
  • crates/macro_db_client/migrations/20260805180315_create_activity_events.sql
  • crates/projects/Cargo.toml
  • crates/projects/src/domain.rs
  • crates/projects/src/domain/activity.rs
  • crates/projects/src/domain/activity/test.rs
  • crates/properties/Cargo.toml
  • crates/properties/src/domain.rs
  • crates/properties/src/domain/activity.rs
  • crates/properties/src/domain/activity/test.rs
  • services/document_storage_service/Cargo.toml
  • services/document_storage_service/src/main.rs
  • services/document_storage_service/src/service/activity.rs
  • services/document_storage_service/src/service/mod.rs

Comment thread crates/call/src/domain/activity.rs
Comment thread crates/email/src/domain/activity.rs Outdated
// A storage failure must abort the run: continuing and
// committing a later record on this partition would
// cumulatively commit past the failed one, losing it
// forever. Returning Err restarts the consumer from the

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of doing this we could have a activity-dead-letter-queue topic that we publish messages to that failed to run. This is a better pattern than having the worker kill itself if it fails as it will cause a massive backup of messages if there is a temporary outage or 1-off issue

Comment on lines +5 to +6
-- uuidv5(source event id, ordinal): one broker event may yield several
-- facts; replays re-derive the same ids, making inserts idempotent.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that is super nice!

Comment on lines +15 to +16
entity_type TEXT NOT NULL,
entity_id TEXT NOT NULL,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we have triggers on the db to automatically delete these rows if the underlying entity is deleted?

I could see us using activity_events as an audit trail table though so we probably wouldn't want deletion of these ever

payloads.as_slice() as &[Option<serde_json::Value>],
&entity_types,
&entity_ids,
&occurred_ats,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we may potentially want to batch and parallelize this call if [Activity] is large enough

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can leave this for the future

-- One append-only table of activity facts: a principal did something to an
-- entity at a time. Every activity surface is a query over this table; no
-- derived tables. See the activity crate for the fact vocabulary.
CREATE TABLE activity_events (

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You need an index on entity_id + entity_type since you have a delete call that uses that index. This will currently result in a full table scan

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's there — idx_activity_events_entity ON (entity_type, entity_id, occurred_at DESC, id DESC) at the bottom of this migration. The purge's WHERE (entity_type, entity_id) IN (...) uses its leading two columns, so no full scan. (The trailing occurred_at, id columns are there so the same index serves the future entity-timeline keyset reads.)

@whutchinson98 whutchinson98 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

needs db index

synoet added 3 commits August 10, 2026 14:00
…l serde_json

- email: MessageSent now requires origin = user_action as well as an actor
  (CodeRabbit) — a send synced from another client no longer records Sent;
  test added for the provider_sync-with-actor case.
- properties: serde_json becomes an unconditional dep — the ungated
  domain::activity module uses it, so minimal-feature consumers broke.
- projects test: purge-cascade test now covers the chat branch (CodeRabbit
  nitpick).
- call: document the deliberate keep of the channel call_started activity
  on RecordDeleted — channel history, dangling payload ref tolerated by
  readers.
@synoet
synoet merged commit e391fbc into main Aug 10, 2026
31 checks passed
@synoet
synoet deleted the synoet/activity-foundation branch August 10, 2026 22:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants