From 94ff4fcd2dd48022b184a3774eb58787b37c848a Mon Sep 17 00:00:00 2001 From: dadachann <185672915+dadachann@users.noreply.github.com> Date: Sun, 26 Jul 2026 12:27:47 +0800 Subject: [PATCH] fix(cloud): preserve Core-owned collaboration state --- src/langbot/pkg/api/http/service/user.py | 4 ++ src/langbot/pkg/cloud/directory_projection.py | 7 +++- .../api/service/test_user_service.py | 9 +++++ .../cloud/test_directory_projection.py | 39 +++++++++++++++++++ 4 files changed, 58 insertions(+), 1 deletion(-) diff --git a/src/langbot/pkg/api/http/service/user.py b/src/langbot/pkg/api/http/service/user.py index 5d602e7b9..f665ff8ac 100644 --- a/src/langbot/pkg/api/http/service/user.py +++ b/src/langbot/pkg/api/http/service/user.py @@ -234,6 +234,10 @@ class UserService: """Create an invited Account and accept its Membership in one transaction.""" normalized_email = normalize_email(user_email) + if self._uses_control_plane_directory(): + raise ControlPlaneDirectoryRequiredError( + 'Cloud invitation registration must use a Space account to preserve control-plane identity' + ) invitation, _ = await self.ap.workspace_collaboration_service.inspect_invitation(invitation_token) if invitation.normalized_email != normalized_email: from ....workspace.collaboration import InvitationEmailMismatchError diff --git a/src/langbot/pkg/cloud/directory_projection.py b/src/langbot/pkg/cloud/directory_projection.py index ca4388afd..dcd8e9d7e 100644 --- a/src/langbot/pkg/cloud/directory_projection.py +++ b/src/langbot/pkg/cloud/directory_projection.py @@ -650,6 +650,11 @@ class DirectoryProjectionService: ) ) continue + if membership.projection_revision == 0: + # Revision zero is Core-owned collaboration state. Directory + # projection seeds memberships, but must not overwrite later + # invitation, role, or removal decisions made by Core. + continue if membership.uuid != member.membership_uuid: raise DirectoryProjectionUnavailableError('Directory membership UUID changed for one account') if membership.projection_revision > member.projection_revision: @@ -664,7 +669,7 @@ class DirectoryProjectionService: membership.projection_revision = member.projection_revision for account_uuid, membership in existing.items(): - if account_uuid not in included_accounts: + if account_uuid not in included_accounts and membership.projection_revision != 0: membership.status = MembershipStatus.REMOVED.value membership.projection_revision = max( int(membership.projection_revision), diff --git a/tests/unit_tests/api/service/test_user_service.py b/tests/unit_tests/api/service/test_user_service.py index 5024ee6cb..ab22712c1 100644 --- a/tests/unit_tests/api/service/test_user_service.py +++ b/tests/unit_tests/api/service/test_user_service.py @@ -639,6 +639,15 @@ class TestUserServiceCreateOrUpdateSpaceUser: persistence.execute_async.assert_not_awaited() + async def test_cloud_invitation_registration_requires_space_identity(self): + ap = SimpleNamespace( + workspace_service=SimpleNamespace(policy=SimpleNamespace(multi_workspace_enabled=True)), + ) + service = UserService(ap) + + with pytest.raises(ControlPlaneDirectoryRequiredError, match='Space account'): + await service.register_invited_account('invite-token', 'member@example.com', 'password') + async def test_create_or_update_new_space_user_first_init(self): """Creates new Space user on first initialization.""" # Setup diff --git a/tests/unit_tests/cloud/test_directory_projection.py b/tests/unit_tests/cloud/test_directory_projection.py index 0e24490e1..38af7ce2f 100644 --- a/tests/unit_tests/cloud/test_directory_projection.py +++ b/tests/unit_tests/cloud/test_directory_projection.py @@ -815,3 +815,42 @@ async def test_snapshot_for_another_instance_is_rejected(projection_context): with pytest.raises(DirectoryProjectionUnavailableError, match='another LangBot instance'): await service.initialize() + + +async def test_core_owned_membership_survives_directory_updates_and_omission(projection_context): + application, session_factory = projection_context + service = DirectoryProjectionService(application, _Provider([_snapshot(1)]), INSTANCE_UUID) + await service.initialize() + + async with session_factory() as session: + async with session.begin(): + membership = await session.scalar(sqlalchemy.select(WorkspaceMembership)) + membership.role = 'viewer' + membership.status = 'active' + membership.projection_revision = 0 + session.add( + WorkspaceMembership( + uuid=SECOND_MEMBERSHIP_UUID, + workspace_uuid=WORKSPACE_UUID, + account_uuid='20000000-0000-0000-0000-000000000099', + role='viewer', + status='active', + joined_at=membership.joined_at, + projection_revision=0, + ) + ) + + projected_member = _member(revision=2).model_copy(update={'role': 'owner', 'membership_status': 'removed'}) + projected_workspace = _workspace(revision=2).model_copy(update={'members': (projected_member,)}) + await service.apply_snapshot(_snapshot(2, workspaces=[projected_workspace])) + + async with session_factory() as session: + memberships = { + membership.uuid: membership + for membership in (await session.scalars(sqlalchemy.select(WorkspaceMembership))).all() + } + assert memberships[MEMBERSHIP_UUID].role == 'viewer' + assert memberships[MEMBERSHIP_UUID].status == 'active' + assert memberships[MEMBERSHIP_UUID].projection_revision == 0 + assert memberships[SECOND_MEMBERSHIP_UUID].status == 'active' + assert memberships[SECOND_MEMBERSHIP_UUID].projection_revision == 0