From 5c23c096412bdbee8c12963eb01a1d5acf9acaa4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 23 Aug 2026 03:22:44 +0000 Subject: [PATCH 1/5] fix(activity): attribute signup defaults and support channel to system Create-time task Status/Assignees still minted a user receipt after #5850, so brand-new accounts showed those writes as You. Thread create attribution into the property write and attribute the signup support channel to system. Co-authored-by: teo --- crates/ai_tools/src/tool_context.rs | 54 ++++++-- crates/channels/src/domain/ports.rs | 14 ++ crates/channels/src/domain/service.rs | 41 +++++- crates/channels/src/domain/service/test.rs | 29 ++++ crates/documents/src/domain/create.rs | 126 ++++++++++++++++++ crates/documents/src/domain/ports.rs | 4 + crates/documents/src/domain/ports/create.rs | 5 +- crates/documents/src/domain/service.rs | 15 ++- crates/documents/src/domain/service/tests.rs | 102 ++++++++++++++ .../src/inbound/axum_router/tests.rs | 2 + .../src/inbound/toolset/edit_document/test.rs | 2 + crates/github/Cargo.toml | 1 + crates/github/src/domain/service/sync/test.rs | 1 + .../api/webhooks/user/create_user_webhook.rs | 6 +- .../src/api/context.rs | 56 ++++++-- 15 files changed, 427 insertions(+), 31 deletions(-) diff --git a/crates/ai_tools/src/tool_context.rs b/crates/ai_tools/src/tool_context.rs index 5209490aac0..c56488021bf 100644 --- a/crates/ai_tools/src/tool_context.rs +++ b/crates/ai_tools/src/tool_context.rs @@ -32,7 +32,7 @@ use documents::{domain::ports::TaskPropertiesPort, inbound::toolset::DocumentToo use email::{ domain::service::EmailServiceImpl, inbound::toolset::EmailToolContext, outbound::EmailPgRepo, }; -use entity_access::domain::models::EditAccessLevel; +use entity_access::domain::models::{BotAccessScope, EditAccessLevel}; use entity_access::domain::ports::EntityAccessService as _; use foreign_entity::{ domain::service::ForeignEntityServiceImpl, @@ -377,6 +377,40 @@ pub fn build_team_tool_context(pool: sqlx::PgPool) -> ToolTeamToolContext { } /// No-op task properties service for tests and contexts that do not create tasks. +async fn task_property_edit_receipt( + entity_access: &A, + user_id: &MacroUserIdStr<'_>, + attribution: &activity::Attribution, + entity_id: &str, +) -> anyhow::Result> { + if let activity::Attribution::Delegated { actor, subject } = attribution + && let Some(bot) = actor.as_bot() + { + return entity_access + .generate_bot_entity_access_receipt::( + bot.bot_id(), + BotAccessScope::User { + user_id: subject.clone(), + user_org_id: None, + }, + entity_id, + model_entity::EntityType::Document, + ) + .await + .map_err(Into::into); + } + + entity_access + .generate_entity_access_receipt::( + user_id, + None, + entity_id, + model_entity::EntityType::Document, + ) + .await + .map_err(Into::into) +} + #[derive(Clone)] pub struct NoOpTaskProperties; @@ -393,6 +427,7 @@ impl TaskPropertiesPort for NoOpTaskProperties { _entity_id: &str, _property_definition_id: uuid::Uuid, _value: Option, + _attribution: &activity::Attribution, ) -> anyhow::Result<()> { Ok(()) } @@ -440,19 +475,18 @@ impl TaskPropertiesPort for TaskPropertiesAdapter { entity_id: &str, property_definition_id: uuid::Uuid, value: Option, + attribution: &activity::Attribution, ) -> anyhow::Result<()> { use properties::PropertiesService as _; let user_id = macro_user_id::user_id::MacroUserIdStr::parse_from_str(user_id)?; - let entity_access_receipt = self - .entity_access_service - .generate_entity_access_receipt::( - &user_id, - None, - entity_id, - model_entity::EntityType::Document, - ) - .await?; + let entity_access_receipt = task_property_edit_receipt( + &*self.entity_access_service, + &user_id, + attribution, + entity_id, + ) + .await?; self.properties .set_entity_property(&entity_access_receipt, property_definition_id, value) .await diff --git a/crates/channels/src/domain/ports.rs b/crates/channels/src/domain/ports.rs index ced2f416e6f..d47f7f0bfbc 100644 --- a/crates/channels/src/domain/ports.rs +++ b/crates/channels/src/domain/ports.rs @@ -664,6 +664,20 @@ pub trait ChannelService: Send + Sync + 'static { } } + /// Create a channel owned by `owner` and attributed to the system principal. + fn create_system_channel( + &self, + _owner: MacroUserIdStr<'static>, + _actor_org_id: Option, + _req: CreateChannelRequest, + ) -> impl Future> + Send { + async move { + Err(ChannelMutationErr::NotFound( + "channel mutations are not configured".to_string(), + )) + } + } + /// Add or reactivate a user in every auto-join channel for a team. fn auto_join_by_team_id( &self, diff --git a/crates/channels/src/domain/service.rs b/crates/channels/src/domain/service.rs index 1322fe38679..e87f734700d 100644 --- a/crates/channels/src/domain/service.rs +++ b/crates/channels/src/domain/service.rs @@ -331,7 +331,31 @@ where _actor_org_id: Option, req: crate::domain::models::CreateChannelRequest, ) -> Result { - let actor = require_user_actor(&actor)?; + let owner = require_user_actor(&actor)?; + self.create_owned_channel(owner.clone(), Sender::new_from_user(owner), req) + .await + } + + #[tracing::instrument(err, skip(self, req))] + async fn create_system_channel( + &self, + owner: MacroUserIdStr<'static>, + req: crate::domain::models::CreateChannelRequest, + ) -> Result { + self.create_owned_channel( + owner, + Sender::new_from_bot(bot_id::MACRO_SYSTEM_BOT_ID), + req, + ) + .await + } + + async fn create_owned_channel( + &self, + owner: MacroUserIdStr<'static>, + activity_actor: Sender, + req: crate::domain::models::CreateChannelRequest, + ) -> Result { if req.auto_join_team && req.channel_type != ChannelType::Team { return Err(ChannelMutationErr::BadRequest( "auto-join is only available for team channels".to_string(), @@ -343,7 +367,7 @@ where })?; let has_team = self .repo - .user_has_team(actor.as_ref().to_string(), team_id) + .user_has_team(owner.as_ref().to_string(), team_id) .await .map_err(|e| ChannelMutationErr::Repo(e.into()))?; if !has_team { @@ -370,12 +394,12 @@ where let channel_name = req.name.clone(); let created_channel = self - .create_channel_record(actor.copied(), org_id, req) + .create_channel_record(owner.copied(), org_id, req) .await?; self.events.dispatch(ChannelEvent::ChannelCreated { channel_id: created_channel.id, - actor: Sender::new_from_user(actor), + actor: activity_actor, channel_type, channel_name, participant_user_ids: created_channel.participant_user_ids, @@ -1788,6 +1812,15 @@ where ChannelServiceImpl::create_channel(self, actor, None, req).await } + async fn create_system_channel( + &self, + owner: MacroUserIdStr<'static>, + _actor_org_id: Option, + req: crate::domain::models::CreateChannelRequest, + ) -> Result { + ChannelServiceImpl::create_system_channel(self, owner, req).await + } + #[tracing::instrument(err, skip(self, user_id))] async fn auto_join_by_team_id( &self, diff --git a/crates/channels/src/domain/service/test.rs b/crates/channels/src/domain/service/test.rs index 7c7a929e950..19a097df380 100644 --- a/crates/channels/src/domain/service/test.rs +++ b/crates/channels/src/domain/service/test.rs @@ -2233,6 +2233,35 @@ async fn remove_participants_allows_removing_non_owner() { assert!(state.touched_channel_ids.is_empty()); } +#[tokio::test] +async fn create_system_channel_event_uses_system_actor() { + let channel_id = Uuid::new_v4(); + let repo = FakeMutationRepo::new(channel_id, "macro|owner@test.com"); + let events = FakeEvents::default(); + let svc = mutation_service(repo, events.clone(), FakeReferenceSharing::default()); + + svc.create_system_channel( + macro_id("macro|owner@test.com"), + crate::domain::models::CreateChannelRequest { + name: Some("Macro Support x owner".to_string()), + channel_type: ChannelType::Private, + team_id: None, + auto_join_team: false, + participants: HashSet::from([macro_id("macro|teo@macro.com")]), + }, + ) + .await + .unwrap(); + + let events = events.events.lock().unwrap(); + assert!(matches!( + events.as_slice(), + [ChannelEvent::ChannelCreated { actor, channel_name: Some(name), .. }] + if actor == &Sender::new_from_bot(bot_id::MACRO_SYSTEM_BOT_ID) + && name == "Macro Support x owner" + )); +} + #[tokio::test] async fn create_channel_event_carries_channel_name() { let channel_id = Uuid::new_v4(); diff --git a/crates/documents/src/domain/create.rs b/crates/documents/src/domain/create.rs index 0d7ae93ddec..7313d4ffb27 100644 --- a/crates/documents/src/domain/create.rs +++ b/crates/documents/src/domain/create.rs @@ -578,6 +578,7 @@ where team_id, }, ); + let attribution = args.resolved_attribution(); let mut response = self .document_service @@ -605,6 +606,7 @@ where property_values, share_with_team, }, + &attribution, ) .await?; } @@ -839,4 +841,128 @@ mod tests { "LPJNul+wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ=" ); } + + #[derive(Default)] + struct RecordingCreationService { + handled: std::sync::Mutex>, + } + + impl super::DocumentCreationService for RecordingCreationService { + async fn create_document( + &self, + user_id: MacroUserIdStr<'static>, + _args: super::CreateDocumentRepoArgs, + _job_id: Option, + ) -> Result { + use crate::domain::content::DocumentContent; + use crate::domain::response::{DocumentResponse, DocumentResponseMetadataWithContent}; + use model::document::response::DocumentResponseMetadata; + + Ok(super::CreateDocumentResponseData { + document_response: DocumentResponse { + document_metadata: DocumentResponseMetadataWithContent::new( + DocumentResponseMetadata { + document_id: "task-1".to_string(), + document_version_id: 1, + owner: user_id, + document_name: "Intro to tasks".to_string(), + file_type: Some("md".to_string()), + sha: Some("sha".to_string()), + branched_from_id: None, + branched_from_version_id: None, + document_family_id: None, + document_bom: None, + modification_data: None, + created_at: None, + updated_at: None, + sub_type: None, + }, + DocumentContent::pending(), + ), + presigned_url: None, + }, + content_type: "text/markdown".to_string(), + file_type: Some("md".to_string()), + }) + } + + async fn handle_task_properties( + &self, + _user_id: MacroUserIdStr<'static>, + _document_id: &str, + _request: &super::CreateTaskRequest, + attribution: &Attribution, + ) -> Result<(), super::DocumentError> { + *self.handled.lock().unwrap() = Some(attribution.clone()); + Ok(()) + } + + async fn mark_document_uploaded( + &self, + _document_id: &str, + ) -> Result<(), super::DocumentError> { + Ok(()) + } + + async fn set_document_content( + &self, + _document_id: &str, + _content: crate::domain::content::DocumentContent, + ) -> Result<(), super::DocumentError> { + Ok(()) + } + + async fn cleanup_created_document(&self, _document_id: &str) {} + } + + struct NoopMarkdown; + + impl crate::domain::ports::markdown::MarkdownInitializationPort for NoopMarkdown { + async fn initialize_existing_markdown( + &self, + _document_id: &str, + _markdown: &str, + ) -> Result, super::DocumentError> { + Ok(vec![1, 2, 3]) + } + } + + struct NoopBytes; + + impl super::DocumentBytesUploadPort for NoopBytes { + async fn upload_document_bytes( + &self, + _upload: super::DocumentBytesUpload, + ) -> Result<(), super::DocumentError> { + Ok(()) + } + } + + #[tokio::test] + async fn create_markdown_task_forwards_resolved_attribution() { + let service = RecordingCreationService::default(); + let creator = super::DocumentCreator::new(&service, NoopMarkdown, NoopBytes); + let attribution = + Attribution::delegated(Actor::new_from_bot(bot_id::MACRO_SYSTEM_BOT_ID), owner()); + + creator + .create_markdown_text( + owner(), + super::NewMarkdownTextDocument { + metadata: NewDocumentMetadata::builder("Intro to tasks") + .attribution(attribution.clone()) + .build(), + markdown: "# intro".to_string(), + subtype: MarkdownSubtype::Task { + property_values: None, + share_with_team: false, + team_id: None, + }, + }, + ) + .await + .unwrap(); + + assert_eq!(service.handled.lock().unwrap().as_ref(), Some(&attribution)); + } } diff --git a/crates/documents/src/domain/ports.rs b/crates/documents/src/domain/ports.rs index be72b817736..7d5363fa736 100644 --- a/crates/documents/src/domain/ports.rs +++ b/crates/documents/src/domain/ports.rs @@ -26,6 +26,8 @@ use model::sync_service::SyncServiceVersionID; use model_entity::Entity; +use activity::Attribution; + use super::models::{ BranchNameContext, CommentThread, CopyDocumentRepoArgs, CreateDocumentRepoArgs, CreateTaskRequest, DocumentError, DocumentTeamShare, DocumentTeamShareResponse, @@ -340,6 +342,7 @@ pub trait TaskPropertiesPort: Send + Sync + 'static { entity_id: &str, property_definition_id: uuid::Uuid, value: Option, + attribution: &Attribution, ) -> impl Future> + Send; /// Copy all task property values from one task to another. @@ -493,6 +496,7 @@ pub trait DocumentService: Send + Sync + 'static { user_id: MacroUserIdStr<'static>, document_id: &str, request: &CreateTaskRequest, + attribution: &Attribution, ) -> impl Future> + Send; /// Returns the raw bytes of the cached Loro snapshot, or `None` if no snapshot exists. diff --git a/crates/documents/src/domain/ports/create.rs b/crates/documents/src/domain/ports/create.rs index ed22c3fe291..86a924730b3 100644 --- a/crates/documents/src/domain/ports/create.rs +++ b/crates/documents/src/domain/ports/create.rs @@ -3,6 +3,7 @@ use std::future::Future; use std::sync::Arc; +use activity::Attribution; use macro_user_id::user_id::MacroUserIdStr; use crate::domain::content::DocumentContent; @@ -46,6 +47,7 @@ pub trait DocumentCreationService: Send + Sync { user_id: MacroUserIdStr<'static>, document_id: &str, request: &CreateTaskRequest, + attribution: &Attribution, ) -> impl Future> + Send; /// Mark a created document's upload/finalization lifecycle as complete. @@ -83,9 +85,10 @@ where user_id: MacroUserIdStr<'static>, document_id: &str, request: &CreateTaskRequest, + attribution: &Attribution, ) -> Result<(), DocumentError> { (**self) - .handle_task_properties(user_id, document_id, request) + .handle_task_properties(user_id, document_id, request, attribution) .await } diff --git a/crates/documents/src/domain/service.rs b/crates/documents/src/domain/service.rs index 6b404f20981..6f9c37e39af 100644 --- a/crates/documents/src/domain/service.rs +++ b/crates/documents/src/domain/service.rs @@ -15,6 +15,7 @@ use std::str::FromStr; use std::time::{SystemTime, UNIX_EPOCH}; use unicode_segmentation::UnicodeSegmentation; +use activity::Attribution; use anyhow::anyhow; use cloudfront_sign::{SignedOptions, get_signed_url}; use connection::domain::models::{InvalidationEvent, InvalidationReason}; @@ -575,8 +576,16 @@ impl< user_id: MacroUserIdStr<'static>, document_id: &str, request: &CreateTaskRequest, + attribution: &Attribution, ) -> Result<(), DocumentError> { - ::handle_task_properties(self, user_id, document_id, request).await + ::handle_task_properties( + self, + user_id, + document_id, + request, + attribution, + ) + .await } #[tracing::instrument(err, skip(self))] @@ -1696,12 +1705,13 @@ impl< } /// Assigns the task properties to a document - #[tracing::instrument(skip(self, request), err)] + #[tracing::instrument(skip(self, request, attribution), err)] async fn handle_task_properties( &self, user_id: MacroUserIdStr<'static>, document_id: &str, request: &CreateTaskRequest, + attribution: &Attribution, ) -> Result<(), DocumentError> { if request.share_with_team && let Some(team_id) = request.team_id @@ -1752,6 +1762,7 @@ impl< document_id, property_uuid, Some(property_input.value.clone()), + attribution, ) .await .inspect_err(|e| { diff --git a/crates/documents/src/domain/service/tests.rs b/crates/documents/src/domain/service/tests.rs index e870b753dfe..68b33b69837 100644 --- a/crates/documents/src/domain/service/tests.rs +++ b/crates/documents/src/domain/service/tests.rs @@ -12,6 +12,7 @@ use crate::domain::models::GithubPullRequest; use crate::domain::ports::{DocumentContentEventService, MockDocumentRepo}; use super::*; +use activity::{Actor, Attribution}; fn make_test_metadata() -> DocumentMetadata { DocumentMetadata { @@ -164,6 +165,7 @@ impl TaskPropertiesPort for TestTaskPropertiesPort { _entity_id: &str, _property_definition_id: uuid::Uuid, _value: Option, + _attribution: &activity::Attribution, ) -> anyhow::Result<()> { Ok(()) } @@ -2038,6 +2040,106 @@ async fn create_document_publishes_resolved_attribution() { ); } +#[derive(Default, Clone)] +struct RecordingTaskPropertiesPort { + writes: Arc>>, +} + +impl TaskPropertiesPort for RecordingTaskPropertiesPort { + async fn attach_task_properties(&self, _entity_ids: Vec) -> anyhow::Result<()> { + Ok(()) + } + + async fn update_task_status(&self, _entity_id: &str, _status: &str) -> anyhow::Result<()> { + Ok(()) + } + + async fn set_entity_property( + &self, + user_id: &str, + _entity_id: &str, + property_definition_id: uuid::Uuid, + _value: Option, + attribution: &Attribution, + ) -> anyhow::Result<()> { + self.writes.lock().unwrap().push(( + user_id.to_string(), + property_definition_id, + attribution.clone(), + )); + Ok(()) + } + + async fn copy_task_properties( + &self, + _from_task_id: &str, + _to_task_id: &str, + ) -> anyhow::Result<()> { + Ok(()) + } +} + +#[tokio::test] +async fn handle_task_properties_forwards_create_attribution() { + use crate::domain::models::{ASSIGNEES_PROPERTY_ID, CreateTaskRequest, STATUS_PROPERTY_ID}; + + let task_properties = RecordingTaskPropertiesPort::default(); + let service = DocumentServiceImpl::new( + make_mock_repo(), + test_cloudfront_config(), + sync_service_client::SyncServiceClient::new( + "test-sync-key".to_string(), + "http://sync-service.test".to_string(), + ), + TestUploadUrlPort, + task_properties.clone(), + TestConnectionService, + TestEntityAccessManagementService::default(), + TestForeignEntityService::default(), + TestEventBroker::default(), + ); + let user = macro_user_id::user_id::MacroUserIdStr::parse_from_str("macro|user@user.com") + .unwrap() + .into_owned(); + let attribution = Attribution::delegated( + Actor::new_from_bot(bot_id::MACRO_SYSTEM_BOT_ID), + user.clone(), + ); + + crate::domain::ports::DocumentService::handle_task_properties( + &service, + user.clone(), + "task-1", + &CreateTaskRequest { + task_name: "Intro to tasks".to_string(), + markdown: None, + project_id: None, + team_id: None, + property_values: None, + share_with_team: false, + }, + &attribution, + ) + .await + .unwrap(); + + let writes = task_properties.writes.lock().unwrap().clone(); + assert_eq!(writes.len(), 2); + assert_eq!( + writes[0].1, + uuid::Uuid::parse_str(ASSIGNEES_PROPERTY_ID).unwrap() + ); + assert_eq!( + writes[1].1, + uuid::Uuid::parse_str(STATUS_PROPERTY_ID).unwrap() + ); + assert!( + writes + .iter() + .all(|(user_id, _, recorded)| user_id == user.as_ref() && recorded == &attribution) + ); +} + #[tokio::test] async fn edited_interaction_bumps_document_before_publishing() { let mut repo = make_mock_repo(); diff --git a/crates/documents/src/inbound/axum_router/tests.rs b/crates/documents/src/inbound/axum_router/tests.rs index 13068befe79..f524a220f57 100644 --- a/crates/documents/src/inbound/axum_router/tests.rs +++ b/crates/documents/src/inbound/axum_router/tests.rs @@ -366,6 +366,7 @@ impl DocumentService for FakeDocumentService { _user_id: MacroUserIdStr<'static>, _document_id: &str, _request: &CreateTaskRequest, + _attribution: &activity::Attribution, ) -> Result<(), DocumentError> { panic!("unexpected handle_task_properties call") } @@ -443,6 +444,7 @@ impl DocumentCreationService for FakeDocumentService { _user_id: MacroUserIdStr<'static>, _document_id: &str, _request: &CreateTaskRequest, + _attribution: &activity::Attribution, ) -> Result<(), DocumentError> { panic!("unexpected creation handle_task_properties call") } diff --git a/crates/documents/src/inbound/toolset/edit_document/test.rs b/crates/documents/src/inbound/toolset/edit_document/test.rs index 97008719896..658eabd04fa 100644 --- a/crates/documents/src/inbound/toolset/edit_document/test.rs +++ b/crates/documents/src/inbound/toolset/edit_document/test.rs @@ -197,6 +197,7 @@ impl DocumentService for FakeDocumentService { _user_id: MacroUserIdStr<'static>, _document_id: &str, _request: &CreateTaskRequest, + _attribution: &activity::Attribution, ) -> Result<(), DocumentError> { panic!("unexpected handle_task_properties call") } @@ -248,6 +249,7 @@ impl DocumentCreationService for FakeDocumentService { _user_id: MacroUserIdStr<'static>, _document_id: &str, _request: &CreateTaskRequest, + _attribution: &activity::Attribution, ) -> Result<(), DocumentError> { panic!("unexpected handle_task_properties call") } diff --git a/crates/github/Cargo.toml b/crates/github/Cargo.toml index 1eb225df2a5..1b363a88b72 100644 --- a/crates/github/Cargo.toml +++ b/crates/github/Cargo.toml @@ -38,6 +38,7 @@ sync = [ ] [dependencies] +activity = { path = "../activity", default-features = false } anyhow = { workspace = true } base64 = { workspace = true } chrono = { workspace = true } diff --git a/crates/github/src/domain/service/sync/test.rs b/crates/github/src/domain/service/sync/test.rs index fe86a062e02..d6ee5f0dc1c 100644 --- a/crates/github/src/domain/service/sync/test.rs +++ b/crates/github/src/domain/service/sync/test.rs @@ -281,6 +281,7 @@ impl DocumentService for StubDocumentService { _user_id: MacroUserIdStr<'static>, _document_id: &str, _request: &documents::domain::models::CreateTaskRequest, + _attribution: &activity::Attribution, ) -> Result<(), DocumentError> { unimplemented!() } diff --git a/services/authentication_service/src/api/webhooks/user/create_user_webhook.rs b/services/authentication_service/src/api/webhooks/user/create_user_webhook.rs index 83d61078c30..db2d224c317 100644 --- a/services/authentication_service/src/api/webhooks/user/create_user_webhook.rs +++ b/services/authentication_service/src/api/webhooks/user/create_user_webhook.rs @@ -18,7 +18,7 @@ use crate::{ use authentication_service::service::user::create_user::create_user; use authentication_service::service::user::support_channel_welcome::post_support_channel_welcome; use channels::domain::{ - models::{ChannelType, CreateChannelRequest, Sender}, + models::{ChannelType, CreateChannelRequest}, ports::ChannelService, }; use favorites::domain::ports::FavoritesService; @@ -390,8 +390,8 @@ async fn create_user_webhook(ctx: &ApiContext, req: FusionAuthUserWebhook) -> an }; let channel = match channel_service - .create_channel( - Sender::new_from_user(owner_id.clone()), + .create_system_channel( + owner_id.clone(), None, CreateChannelRequest { name: Some(support_channel_name), diff --git a/services/document_storage_service/src/api/context.rs b/services/document_storage_service/src/api/context.rs index 9bde05c02cc..0a154350171 100644 --- a/services/document_storage_service/src/api/context.rs +++ b/services/document_storage_service/src/api/context.rs @@ -53,7 +53,9 @@ use email::{ }; use entity_access::{ domain::{ - models::EditAccessLevel, ports::EntityAccessService as _, service::EntityAccessServiceImpl, + models::{BotAccessScope, EditAccessLevel}, + ports::EntityAccessService as _, + service::EntityAccessServiceImpl, }, outbound::PgAccessRepository, }; @@ -222,6 +224,40 @@ pub(crate) type DssCalendarService = CalendarService; /// Calendar occurrence router state. pub(crate) type DssCalendarState = CalendarRouterState; +async fn task_property_edit_receipt( + entity_access: &EntityAccessService, + user_id: ¯o_user_id::user_id::MacroUserIdStr<'_>, + attribution: &activity::Attribution, + entity_id: &str, +) -> anyhow::Result> { + if let activity::Attribution::Delegated { actor, subject } = attribution + && let Some(bot) = actor.as_bot() + { + return entity_access + .generate_bot_entity_access_receipt::( + bot.bot_id(), + BotAccessScope::User { + user_id: subject.clone(), + user_org_id: None, + }, + entity_id, + model_entity::EntityType::Document, + ) + .await + .map_err(Into::into); + } + + entity_access + .generate_entity_access_receipt::( + user_id, + None, + entity_id, + model_entity::EntityType::Document, + ) + .await + .map_err(Into::into) +} + /// Adapter implementing [`TaskPropertiesPort`] for the system properties service. pub(crate) struct TaskPropertiesAdapter { pub system_properties: Arc, @@ -253,20 +289,18 @@ impl TaskPropertiesPort for TaskPropertiesAdapter { entity_id: &str, property_definition_id: uuid::Uuid, value: Option, + attribution: &activity::Attribution, ) -> anyhow::Result<()> { use properties::PropertiesService as _; let user_id = macro_user_id::user_id::MacroUserIdStr::parse_from_str(user_id)?; - - let entity_access_receipt = self - .entity_access_service - .generate_entity_access_receipt::( - &user_id, - None, - entity_id, - model_entity::EntityType::Document, - ) - .await?; + let entity_access_receipt = task_property_edit_receipt( + &*self.entity_access_service, + &user_id, + attribution, + entity_id, + ) + .await?; self.properties .set_entity_property(&entity_access_receipt, property_definition_id, value) .await From 7a33763e08fe615033d9ef4d6b67f9b618728e0a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 23 Aug 2026 03:27:20 +0000 Subject: [PATCH 2/5] fix(activity): pass attribution on imported task property writes Co-authored-by: teo --- crates/ai_tools/src/tool_context.rs | 6 ++++++ crates/documents/src/domain/create.rs | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/ai_tools/src/tool_context.rs b/crates/ai_tools/src/tool_context.rs index c56488021bf..6d69ef5cabc 100644 --- a/crates/ai_tools/src/tool_context.rs +++ b/crates/ai_tools/src/tool_context.rs @@ -1053,6 +1053,9 @@ impl ToolEntityCreator { use models_properties::api::requests::SetPropertyValue; use system_properties::SystemPropertyKey; + let attribution = + activity::Attribution::direct(activity::Actor::new_from_user(user.clone())); + if let Some(status) = properties.status.as_deref() && let Err(e) = self .task_properties @@ -1081,6 +1084,7 @@ impl ToolEntityCreator { Some(SetPropertyValue::SelectOption { option_id: option.uuid(), }), + &attribution, ) .await { @@ -1102,6 +1106,7 @@ impl ToolEntityCreator { task_id, SystemPropertyKey::DueDate.uuid(), Some(SetPropertyValue::Date { value }), + &attribution, ) .await { @@ -1136,6 +1141,7 @@ impl ToolEntityCreator { Some(SetPropertyValue::MultiEntityReference { references: vec![reference], }), + &attribution, ) .await { diff --git a/crates/documents/src/domain/create.rs b/crates/documents/src/domain/create.rs index 7313d4ffb27..b735b1af8b8 100644 --- a/crates/documents/src/domain/create.rs +++ b/crates/documents/src/domain/create.rs @@ -940,8 +940,8 @@ mod tests { #[tokio::test] async fn create_markdown_task_forwards_resolved_attribution() { - let service = RecordingCreationService::default(); - let creator = super::DocumentCreator::new(&service, NoopMarkdown, NoopBytes); + let service = std::sync::Arc::new(RecordingCreationService::default()); + let creator = super::DocumentCreator::new(service.clone(), NoopMarkdown, NoopBytes); let attribution = Attribution::delegated(Actor::new_from_bot(bot_id::MACRO_SYSTEM_BOT_ID), owner()); From fc17a845c6f40d8b09b7ed19abf2ca051eec26e0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 30 Aug 2026 18:22:20 +0000 Subject: [PATCH 3/5] fix(github): lock the activity path dependency CI runs `cargo run -p xtask --locked -- deps --check`. crates/github already depends on activity, but Cargo.lock omitted it. Co-authored-by: teo --- Cargo.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/Cargo.lock b/Cargo.lock index 484d66e5b0f..f1027cebdcb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6662,6 +6662,7 @@ dependencies = [ name = "github" version = "0.1.0" dependencies = [ + "activity", "anyhow", "axum", "axum-extra", From 0f84f35b095aafbb4b1d8f46a9e46923175607f5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 30 Aug 2026 18:34:30 +0000 Subject: [PATCH 4/5] fix(activity): drop explicit auto-deref on task property receipts Cloud Storage check failed clippy::explicit_auto_deref. Both adapters now pass Arc::as_ref() into task_property_edit_receipt. Co-authored-by: teo --- crates/ai_tools/src/tool_context.rs | 2 +- services/document_storage_service/src/api/context.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ai_tools/src/tool_context.rs b/crates/ai_tools/src/tool_context.rs index 6d69ef5cabc..805af188a10 100644 --- a/crates/ai_tools/src/tool_context.rs +++ b/crates/ai_tools/src/tool_context.rs @@ -481,7 +481,7 @@ impl TaskPropertiesPort for TaskPropertiesAdapter { let user_id = macro_user_id::user_id::MacroUserIdStr::parse_from_str(user_id)?; let entity_access_receipt = task_property_edit_receipt( - &*self.entity_access_service, + self.entity_access_service.as_ref(), &user_id, attribution, entity_id, diff --git a/services/document_storage_service/src/api/context.rs b/services/document_storage_service/src/api/context.rs index 0a154350171..eb0771e146f 100644 --- a/services/document_storage_service/src/api/context.rs +++ b/services/document_storage_service/src/api/context.rs @@ -295,7 +295,7 @@ impl TaskPropertiesPort for TaskPropertiesAdapter { let user_id = macro_user_id::user_id::MacroUserIdStr::parse_from_str(user_id)?; let entity_access_receipt = task_property_edit_receipt( - &*self.entity_access_service, + self.entity_access_service.as_ref(), &user_id, attribution, entity_id, From 63a1cae041cfa016e6b2eb69a4a21d38d036a78f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 30 Aug 2026 19:16:32 +0000 Subject: [PATCH 5/5] test(activity): reproduce signup You-attribution on support channel create Pin the main-branch webhook path: create_channel with a user sender emits ChannelCreated as the owner, which the feed renders as You. Co-authored-by: teo --- crates/channels/src/domain/activity/test.rs | 1 + crates/channels/src/domain/service/test.rs | 32 +++++++++++++++++++ crates/properties/src/domain/activity/test.rs | 5 ++- 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/crates/channels/src/domain/activity/test.rs b/crates/channels/src/domain/activity/test.rs index c621018d5a0..51155832dfc 100644 --- a/crates/channels/src/domain/activity/test.rs +++ b/crates/channels/src/domain/activity/test.rs @@ -103,5 +103,6 @@ fn created_maps_to_created_by_the_actor() { panic!("expected activities"); }; assert_eq!(activities[0].action, Action::Created); + assert_eq!(activities[0].actor.as_ref(), "macro|owner@example.com"); assert_eq!(activities[0].subject_id, "macro|owner@example.com"); } diff --git a/crates/channels/src/domain/service/test.rs b/crates/channels/src/domain/service/test.rs index 19a097df380..129a39e21dc 100644 --- a/crates/channels/src/domain/service/test.rs +++ b/crates/channels/src/domain/service/test.rs @@ -2262,6 +2262,38 @@ async fn create_system_channel_event_uses_system_actor() { )); } +/// Signup on main called `create_channel(Sender::new_from_user(owner))`. +/// That is the path that made "Created # Macro Support x …" render as You. +#[tokio::test] +async fn signup_support_channel_via_user_create_channel_attributes_created_to_owner() { + let channel_id = Uuid::new_v4(); + let repo = FakeMutationRepo::new(channel_id, "macro|owner@test.com"); + let events = FakeEvents::default(); + let svc = mutation_service(repo, events.clone(), FakeReferenceSharing::default()); + + svc.create_channel( + sender("macro|owner@test.com"), + None, + crate::domain::models::CreateChannelRequest { + name: Some("Macro Support x owner".to_string()), + channel_type: ChannelType::Private, + team_id: None, + auto_join_team: false, + participants: HashSet::from([macro_id("macro|teo@macro.com")]), + }, + ) + .await + .unwrap(); + + let events = events.events.lock().unwrap(); + assert!(matches!( + events.as_slice(), + [ChannelEvent::ChannelCreated { actor, channel_name: Some(name), .. }] + if actor == &sender("macro|owner@test.com") + && name == "Macro Support x owner" + )); +} + #[tokio::test] async fn create_channel_event_carries_channel_name() { let channel_id = Uuid::new_v4(); diff --git a/crates/properties/src/domain/activity/test.rs b/crates/properties/src/domain/activity/test.rs index acfeac1f94d..82f8dbff264 100644 --- a/crates/properties/src/domain/activity/test.rs +++ b/crates/properties/src/domain/activity/test.rs @@ -64,8 +64,11 @@ fn task_property_update_maps_to_property_changed_on_the_document() { let Ingest::Insert(activities) = event.event.ingest(event.event_id) else { panic!("expected activities"); }; - // Tasks are documents in the soup vocabulary. + // Tasks are documents in the soup vocabulary. A user-only + // `actor_user_id` (main's TaskPropertiesAdapter receipt) is Direct(user): + // the feed renders "You" for that owner. assert_eq!(activities[0].entity_type, ActivityEntityType::Document); + assert_eq!(activities[0].actor.as_ref(), "macro|seamus@example.com"); assert_eq!(activities[0].subject_id, "macro|seamus@example.com"); match &activities[0].action { Action::PropertyChanged(change) => {