refactor(db): rename SearchSpace ORM core to Workspace (Phase 2 Wave A)

Flip the symbolic name in db.py: classes SearchSpace/Role/Membership/Invite ->
Workspace*, attributes search_space_id -> workspace_id (and owner_*), relationship
attrs + back_populates pairs, and drop the Phase 1 Column("workspace_id", ...) shim
now that attribute name == column name. Enum values 'SEARCH_SPACE' and the
SearchSourceConnector class are intentionally untouched (carve-outs).

Part of the atomic SearchSpace -> Workspace rename; the suite goes green only once
Waves B-F land (no half-renamed steady state).
This commit is contained in:
CREDO23 2026-06-26 18:18:50 +02:00
parent 49d3001fbf
commit df03565940

View file

@ -292,7 +292,7 @@ INCENTIVE_TASKS_CONFIG = {
class Permission(StrEnum):
"""
Granular permissions for search space resources.
Granular permissions for workspace resources.
Use '*' (FULL_ACCESS) to grant all permissions.
"""
@ -363,10 +363,10 @@ class Permission(StrEnum):
ROLES_UPDATE = "roles:update"
ROLES_DELETE = "roles:delete"
# Search Space Settings
# Workspace Settings
SETTINGS_VIEW = "settings:view"
SETTINGS_UPDATE = "settings:update"
SETTINGS_DELETE = "settings:delete" # Delete the entire search space
SETTINGS_DELETE = "settings:delete" # Delete the entire workspace
# API Access
API_ACCESS_MANAGE = "api_access:manage"
@ -515,7 +515,7 @@ class ChatVisibility(StrEnum):
Visibility/sharing level for chat threads.
PRIVATE: Only the creator can see/access the chat (default)
SEARCH_SPACE: All members of the search space can see/access the chat
SEARCH_SPACE: All members of the workspace can see/access the chat
PUBLIC: (Future) Anyone with the link can access the chat
"""
@ -605,8 +605,7 @@ class NewChatThread(BaseModel, TimestampMixin):
)
# Foreign keys
search_space_id = Column(
"workspace_id",
workspace_id = Column(
Integer,
ForeignKey("workspaces.id", ondelete="CASCADE"),
nullable=False,
@ -647,7 +646,7 @@ class NewChatThread(BaseModel, TimestampMixin):
# Auto model pin for this thread: concrete resolved global LLM
# config id. NULL means no pin; Auto will resolve on the next turn.
# Single-writer invariant: only app.services.auto_model_pin_service sets
# or clears this column (plus bulk clears when a search space's
# or clears this column (plus bulk clears when a workspace's
# chat_model_id changes). Unindexed: all reads are by primary key.
pinned_llm_config_id = Column(Integer, nullable=True)
@ -664,7 +663,7 @@ class NewChatThread(BaseModel, TimestampMixin):
)
# Relationships
search_space = relationship("SearchSpace", back_populates="new_chat_threads")
workspace = relationship("Workspace", back_populates="new_chat_threads")
created_by = relationship("User", back_populates="new_chat_threads")
messages = relationship(
"NewChatMessage",
@ -788,8 +787,7 @@ class ExternalChatAccount(Base, TimestampMixin):
owner_user_id = Column(
UUID(as_uuid=True), ForeignKey("user.id", ondelete="CASCADE"), nullable=True
)
owner_search_space_id = Column(
"owner_workspace_id",
owner_workspace_id = Column(
Integer,
ForeignKey("workspaces.id", ondelete="CASCADE"),
nullable=True,
@ -825,8 +823,8 @@ class ExternalChatAccount(Base, TimestampMixin):
)
owner = relationship("User", foreign_keys=[owner_user_id])
owner_search_space = relationship(
"SearchSpace", foreign_keys=[owner_search_space_id]
owner_workspace = relationship(
"Workspace", foreign_keys=[owner_workspace_id]
)
bindings = relationship(
"ExternalChatBinding",
@ -903,8 +901,7 @@ class ExternalChatBinding(Base, TimestampMixin):
user_id = Column(
UUID(as_uuid=True), ForeignKey("user.id", ondelete="CASCADE"), nullable=False
)
search_space_id = Column(
"workspace_id",
workspace_id = Column(
Integer,
ForeignKey("workspaces.id", ondelete="CASCADE"),
nullable=False,
@ -957,7 +954,7 @@ class ExternalChatBinding(Base, TimestampMixin):
account = relationship("ExternalChatAccount", back_populates="bindings")
user = relationship("User", foreign_keys=[user_id])
search_space = relationship("SearchSpace", foreign_keys=[search_space_id])
workspace = relationship("Workspace", foreign_keys=[workspace_id])
new_chat_thread = relationship("NewChatThread", foreign_keys=[new_chat_thread_id])
threads = relationship(
"NewChatThread",
@ -1122,8 +1119,7 @@ class TokenUsage(BaseModel, TimestampMixin):
nullable=True,
index=True,
)
search_space_id = Column(
"workspace_id",
workspace_id = Column(
Integer,
ForeignKey("workspaces.id", ondelete="CASCADE"),
nullable=False,
@ -1139,7 +1135,7 @@ class TokenUsage(BaseModel, TimestampMixin):
# Relationships
thread = relationship("NewChatThread", back_populates="token_usages")
message = relationship("NewChatMessage", back_populates="token_usage")
search_space = relationship("SearchSpace")
workspace = relationship("Workspace")
user = relationship("User")
@ -1328,8 +1324,7 @@ class Folder(BaseModel, TimestampMixin):
nullable=True,
index=True,
)
search_space_id = Column(
"workspace_id",
workspace_id = Column(
Integer,
ForeignKey("workspaces.id", ondelete="CASCADE"),
nullable=False,
@ -1351,7 +1346,7 @@ class Folder(BaseModel, TimestampMixin):
folder_metadata = Column("metadata", JSONB, nullable=True)
parent = relationship("Folder", remote_side="Folder.id", backref="children")
search_space = relationship("SearchSpace", back_populates="folders")
workspace = relationship("Workspace", back_populates="folders")
created_by = relationship("User", back_populates="folders")
documents = relationship("Document", back_populates="folder", passive_deletes=True)
@ -1368,7 +1363,7 @@ class Document(BaseModel, TimestampMixin):
# filesystem two files at different paths can hold identical bytes,
# and the agent's ``write_file`` flow needs that semantic to support
# copy / duplicate operations. Path uniqueness lives on
# ``unique_identifier_hash`` (per search space). The hash remains
# ``unique_identifier_hash`` (per workspace). The hash remains
# indexed because connector indexers consult it as a change-detection
# / cross-source dedup hint via :func:`check_duplicate_document`.
# See migration 133.
@ -1393,8 +1388,7 @@ class Document(BaseModel, TimestampMixin):
# Track when document was last updated by indexers, processors, or editor
updated_at = Column(TIMESTAMP(timezone=True), nullable=True, index=True)
search_space_id = Column(
"workspace_id",
workspace_id = Column(
Integer,
ForeignKey("workspaces.id", ondelete="CASCADE"),
nullable=False,
@ -1435,7 +1429,7 @@ class Document(BaseModel, TimestampMixin):
)
# Relationships
search_space = relationship("SearchSpace", back_populates="documents")
workspace = relationship("Workspace", back_populates="documents")
folder = relationship("Folder", back_populates="documents")
created_by = relationship("User", back_populates="documents")
connector = relationship("SearchSourceConnector", back_populates="documents")
@ -1520,13 +1514,12 @@ class VideoPresentation(BaseModel, TimestampMixin):
index=True,
)
search_space_id = Column(
"workspace_id",
workspace_id = Column(
Integer,
ForeignKey("workspaces.id", ondelete="CASCADE"),
nullable=False,
)
search_space = relationship("SearchSpace", back_populates="video_presentations")
workspace = relationship("Workspace", back_populates="video_presentations")
thread_id = Column(
Integer,
@ -1550,13 +1543,12 @@ class Report(BaseModel, TimestampMixin):
String(100), nullable=True
) # e.g. "executive_summary", "deep_research"
search_space_id = Column(
"workspace_id",
workspace_id = Column(
Integer,
ForeignKey("workspaces.id", ondelete="CASCADE"),
nullable=False,
)
search_space = relationship("SearchSpace", back_populates="reports")
workspace = relationship("Workspace", back_populates="reports")
# Versioning: reports sharing the same report_group_id are versions of the same report.
# For v1, report_group_id = the report's own id (set after insert).
@ -1581,8 +1573,7 @@ class Connection(BaseModel, TimestampMixin):
scope = Column(SQLAlchemyEnum(ConnectionScope), nullable=False, index=True)
enabled = Column(Boolean, nullable=False, default=True, server_default="true")
search_space_id = Column(
"workspace_id",
workspace_id = Column(
Integer,
ForeignKey("workspaces.id", ondelete="CASCADE"),
nullable=True,
@ -1591,7 +1582,7 @@ class Connection(BaseModel, TimestampMixin):
UUID(as_uuid=True), ForeignKey("user.id", ondelete="CASCADE"), nullable=True
)
search_space = relationship("SearchSpace", back_populates="connections")
workspace = relationship("Workspace", back_populates="connections")
user = relationship("User", back_populates="connections")
models = relationship(
"Model",
@ -1695,8 +1686,7 @@ class ImageGeneration(BaseModel, TimestampMixin):
access_token = Column(String(64), nullable=True, index=True)
# Foreign keys
search_space_id = Column(
"workspace_id",
workspace_id = Column(
Integer,
ForeignKey("workspaces.id", ondelete="CASCADE"),
nullable=False,
@ -1709,11 +1699,11 @@ class ImageGeneration(BaseModel, TimestampMixin):
)
# Relationships
search_space = relationship("SearchSpace", back_populates="image_generations")
workspace = relationship("Workspace", back_populates="image_generations")
created_by = relationship("User", back_populates="image_generations")
class SearchSpace(BaseModel, TimestampMixin):
class Workspace(BaseModel, TimestampMixin):
__tablename__ = "workspaces"
name = Column(String(100), nullable=False, index=True)
@ -1735,7 +1725,7 @@ class SearchSpace(BaseModel, TimestampMixin):
# Note: ID values preserve the existing convention:
# - 0: Auto mode
# - Negative IDs: Global virtual models from global_llm_config.yaml
# - Positive IDs: User/search-space models from the models table
# - Positive IDs: User/workspace models from the models table
chat_model_id = Column(
Integer, nullable=True, default=0, server_default="0"
) # For agent/chat operations, defaults to Auto mode
@ -1753,71 +1743,71 @@ class SearchSpace(BaseModel, TimestampMixin):
user_id = Column(
UUID(as_uuid=True), ForeignKey("user.id", ondelete="CASCADE"), nullable=False
)
user = relationship("User", back_populates="search_spaces")
user = relationship("User", back_populates="workspaces")
folders = relationship(
"Folder",
back_populates="search_space",
back_populates="workspace",
order_by="Folder.position",
cascade="all, delete-orphan",
)
documents = relationship(
"Document",
back_populates="search_space",
back_populates="workspace",
order_by="Document.id",
cascade="all, delete-orphan",
)
new_chat_threads = relationship(
"NewChatThread",
back_populates="search_space",
back_populates="workspace",
order_by="NewChatThread.updated_at.desc()",
cascade="all, delete-orphan",
)
podcasts = relationship(
"Podcast",
back_populates="search_space",
back_populates="workspace",
order_by="Podcast.id.desc()",
cascade="all, delete-orphan",
)
video_presentations = relationship(
"VideoPresentation",
back_populates="search_space",
back_populates="workspace",
order_by="VideoPresentation.id.desc()",
cascade="all, delete-orphan",
)
reports = relationship(
"Report",
back_populates="search_space",
back_populates="workspace",
order_by="Report.id.desc()",
cascade="all, delete-orphan",
)
image_generations = relationship(
"ImageGeneration",
back_populates="search_space",
back_populates="workspace",
order_by="ImageGeneration.id.desc()",
cascade="all, delete-orphan",
)
logs = relationship(
"Log",
back_populates="search_space",
back_populates="workspace",
order_by="Log.id",
cascade="all, delete-orphan",
)
notifications = relationship(
"Notification",
back_populates="search_space",
back_populates="workspace",
order_by="Notification.created_at.desc()",
cascade="all, delete-orphan",
)
search_source_connectors = relationship(
"SearchSourceConnector",
back_populates="search_space",
back_populates="workspace",
order_by="SearchSourceConnector.id",
cascade="all, delete-orphan",
)
connections = relationship(
"Connection",
back_populates="search_space",
back_populates="workspace",
order_by="Connection.id",
cascade="all, delete-orphan",
passive_deletes=True,
@ -1825,7 +1815,7 @@ class SearchSpace(BaseModel, TimestampMixin):
automations = relationship(
"Automation",
back_populates="search_space",
back_populates="workspace",
order_by="Automation.id",
cascade="all, delete-orphan",
passive_deletes=True,
@ -1833,21 +1823,21 @@ class SearchSpace(BaseModel, TimestampMixin):
# RBAC relationships
roles = relationship(
"SearchSpaceRole",
back_populates="search_space",
order_by="SearchSpaceRole.id",
"WorkspaceRole",
back_populates="workspace",
order_by="WorkspaceRole.id",
cascade="all, delete-orphan",
)
memberships = relationship(
"SearchSpaceMembership",
back_populates="search_space",
order_by="SearchSpaceMembership.id",
"WorkspaceMembership",
back_populates="workspace",
order_by="WorkspaceMembership.id",
cascade="all, delete-orphan",
)
invites = relationship(
"SearchSpaceInvite",
back_populates="search_space",
order_by="SearchSpaceInvite.id",
"WorkspaceInvite",
back_populates="workspace",
order_by="WorkspaceInvite.id",
cascade="all, delete-orphan",
)
@ -1907,14 +1897,13 @@ class SearchSourceConnector(BaseModel, TimestampMixin):
indexing_frequency_minutes = Column(Integer, nullable=True)
next_scheduled_at = Column(TIMESTAMP(timezone=True), nullable=True)
search_space_id = Column(
"workspace_id",
workspace_id = Column(
Integer,
ForeignKey("workspaces.id", ondelete="CASCADE"),
nullable=False,
)
search_space = relationship(
"SearchSpace", back_populates="search_source_connectors"
workspace = relationship(
"Workspace", back_populates="search_source_connectors"
)
user_id = Column(
@ -1937,13 +1926,12 @@ class Log(BaseModel, TimestampMixin):
) # Service/component that generated the log
log_metadata = Column(JSON, nullable=True, default={}) # Additional context data
search_space_id = Column(
"workspace_id",
workspace_id = Column(
Integer,
ForeignKey("workspaces.id", ondelete="CASCADE"),
nullable=False,
)
search_space = relationship("SearchSpace", back_populates="logs")
workspace = relationship("Workspace", back_populates="logs")
class UserIncentiveTask(BaseModel, TimestampMixin):
@ -2056,10 +2044,10 @@ class CreditPurchase(Base, TimestampMixin):
user = relationship("User", back_populates="credit_purchases")
class SearchSpaceRole(BaseModel, TimestampMixin):
class WorkspaceRole(BaseModel, TimestampMixin):
"""
Custom roles that can be defined per search space.
Each search space can have multiple roles with different permission sets.
Custom roles that can be defined per workspace.
Each workspace can have multiple roles with different permission sets.
"""
__tablename__ = "workspace_roles"
@ -2080,26 +2068,25 @@ class SearchSpaceRole(BaseModel, TimestampMixin):
# System roles (Owner, Editor, Viewer) cannot be deleted
is_system_role = Column(Boolean, nullable=False, default=False)
search_space_id = Column(
"workspace_id",
workspace_id = Column(
Integer,
ForeignKey("workspaces.id", ondelete="CASCADE"),
nullable=False,
)
search_space = relationship("SearchSpace", back_populates="roles")
workspace = relationship("Workspace", back_populates="roles")
memberships = relationship(
"SearchSpaceMembership", back_populates="role", passive_deletes=True
"WorkspaceMembership", back_populates="role", passive_deletes=True
)
invites = relationship(
"SearchSpaceInvite", back_populates="role", passive_deletes=True
"WorkspaceInvite", back_populates="role", passive_deletes=True
)
class SearchSpaceMembership(BaseModel, TimestampMixin):
class WorkspaceMembership(BaseModel, TimestampMixin):
"""
Tracks user membership in search spaces with their assigned role.
Each user can be a member of multiple search spaces with different roles.
Tracks user membership in workspaces with their assigned role.
Each user can be a member of multiple workspaces with different roles.
"""
__tablename__ = "workspace_memberships"
@ -2114,8 +2101,7 @@ class SearchSpaceMembership(BaseModel, TimestampMixin):
user_id = Column(
UUID(as_uuid=True), ForeignKey("user.id", ondelete="CASCADE"), nullable=False
)
search_space_id = Column(
"workspace_id",
workspace_id = Column(
Integer,
ForeignKey("workspaces.id", ondelete="CASCADE"),
nullable=False,
@ -2125,7 +2111,7 @@ class SearchSpaceMembership(BaseModel, TimestampMixin):
ForeignKey("workspace_roles.id", ondelete="SET NULL"),
nullable=True,
)
# Indicates if this user is the original creator/owner of the search space
# Indicates if this user is the original creator/owner of the workspace
is_owner = Column(Boolean, nullable=False, default=False)
# Timestamp when the user joined (via invite or as creator)
joined_at = Column(
@ -2140,17 +2126,17 @@ class SearchSpaceMembership(BaseModel, TimestampMixin):
nullable=True,
)
user = relationship("User", back_populates="search_space_memberships")
search_space = relationship("SearchSpace", back_populates="memberships")
role = relationship("SearchSpaceRole", back_populates="memberships")
user = relationship("User", back_populates="workspace_memberships")
workspace = relationship("Workspace", back_populates="memberships")
role = relationship("WorkspaceRole", back_populates="memberships")
invited_by_invite = relationship(
"SearchSpaceInvite", back_populates="used_by_memberships"
"WorkspaceInvite", back_populates="used_by_memberships"
)
class SearchSpaceInvite(BaseModel, TimestampMixin):
class WorkspaceInvite(BaseModel, TimestampMixin):
"""
Invite links for search spaces.
Invite links for workspaces.
Users can create invite links with specific roles that others can use to join.
"""
@ -2159,8 +2145,7 @@ class SearchSpaceInvite(BaseModel, TimestampMixin):
# Unique invite code (used in invite URLs)
invite_code = Column(String(64), nullable=False, unique=True, index=True)
search_space_id = Column(
"workspace_id",
workspace_id = Column(
Integer,
ForeignKey("workspaces.id", ondelete="CASCADE"),
nullable=False,
@ -2189,11 +2174,11 @@ class SearchSpaceInvite(BaseModel, TimestampMixin):
# Optional custom name/label for the invite
name = Column(String(100), nullable=True)
search_space = relationship("SearchSpace", back_populates="invites")
role = relationship("SearchSpaceRole", back_populates="invites")
workspace = relationship("Workspace", back_populates="invites")
role = relationship("WorkspaceRole", back_populates="invites")
created_by = relationship("User", back_populates="created_invites")
used_by_memberships = relationship(
"SearchSpaceMembership",
"WorkspaceMembership",
back_populates="invited_by_invite",
passive_deletes=True,
)
@ -2220,8 +2205,7 @@ class Prompt(BaseModel, TimestampMixin):
nullable=False,
index=True,
)
search_space_id = Column(
"workspace_id",
workspace_id = Column(
Integer,
ForeignKey("workspaces.id", ondelete="CASCADE"),
nullable=True,
@ -2238,7 +2222,7 @@ class Prompt(BaseModel, TimestampMixin):
is_public = Column(Boolean, nullable=False, default=False)
user = relationship("User")
search_space = relationship("SearchSpace")
workspace = relationship("Workspace")
if config.AUTH_TYPE == "GOOGLE":
@ -2250,7 +2234,7 @@ if config.AUTH_TYPE == "GOOGLE":
oauth_accounts: Mapped[list[OAuthAccount]] = relationship(
"OAuthAccount", lazy="joined"
)
search_spaces = relationship("SearchSpace", back_populates="user")
workspaces = relationship("Workspace", back_populates="user")
notifications = relationship(
"Notification",
back_populates="user",
@ -2259,13 +2243,13 @@ if config.AUTH_TYPE == "GOOGLE":
)
# RBAC relationships
search_space_memberships = relationship(
"SearchSpaceMembership",
workspace_memberships = relationship(
"WorkspaceMembership",
back_populates="user",
cascade="all, delete-orphan",
)
created_invites = relationship(
"SearchSpaceInvite",
"WorkspaceInvite",
back_populates="created_by",
passive_deletes=True,
)
@ -2387,7 +2371,7 @@ if config.AUTH_TYPE == "GOOGLE":
else:
class User(SQLAlchemyBaseUserTableUUID, Base):
search_spaces = relationship("SearchSpace", back_populates="user")
workspaces = relationship("Workspace", back_populates="user")
notifications = relationship(
"Notification",
back_populates="user",
@ -2396,13 +2380,13 @@ else:
)
# RBAC relationships
search_space_memberships = relationship(
"SearchSpaceMembership",
workspace_memberships = relationship(
"WorkspaceMembership",
back_populates="user",
cascade="all, delete-orphan",
)
created_invites = relationship(
"SearchSpaceInvite",
"WorkspaceInvite",
back_populates="created_by",
passive_deletes=True,
)
@ -2550,8 +2534,7 @@ class AgentActionLog(BaseModel):
nullable=True,
index=True,
)
search_space_id = Column(
"workspace_id",
workspace_id = Column(
Integer,
ForeignKey("workspaces.id", ondelete="CASCADE"),
nullable=False,
@ -2622,8 +2605,7 @@ class DocumentRevision(BaseModel):
nullable=True,
index=True,
)
search_space_id = Column(
"workspace_id",
workspace_id = Column(
Integer,
ForeignKey("workspaces.id", ondelete="CASCADE"),
nullable=False,
@ -2664,8 +2646,7 @@ class FolderRevision(BaseModel):
nullable=True,
index=True,
)
search_space_id = Column(
"workspace_id",
workspace_id = Column(
Integer,
ForeignKey("workspaces.id", ondelete="CASCADE"),
nullable=False,
@ -2693,7 +2674,7 @@ class FolderRevision(BaseModel):
class AgentPermissionRule(BaseModel):
"""Persistent permission rule consumed by :class:`PermissionMiddleware`.
Scoped at one of: search-space-wide (``user_id`` and ``thread_id`` NULL),
Scoped at one of: workspace-wide (``user_id`` and ``thread_id`` NULL),
user-wide (``user_id`` set, ``thread_id`` NULL), or per-thread
(``thread_id`` set). Loaded at agent build time and converted to
:class:`Rule` instances inside the agent factory.
@ -2701,8 +2682,7 @@ class AgentPermissionRule(BaseModel):
__tablename__ = "agent_permission_rules"
search_space_id = Column(
"workspace_id",
workspace_id = Column(
Integer,
ForeignKey("workspaces.id", ondelete="CASCADE"),
nullable=False,
@ -3080,10 +3060,10 @@ def has_all_permissions(
def get_default_roles_config() -> list[dict]:
"""
Get the configuration for default system roles.
These roles are created automatically when a search space is created.
These roles are created automatically when a workspace is created.
Only 3 roles are supported:
- Owner: Full access to everything (assigned to search space creator)
- Owner: Full access to everything (assigned to workspace creator)
- Editor: Can create/update content but cannot delete, manage roles, or change settings
- Viewer: Read-only access to resources (can add comments)
@ -3093,7 +3073,7 @@ def get_default_roles_config() -> list[dict]:
return [
{
"name": "Owner",
"description": "Full access to all search space resources and settings",
"description": "Full access to all workspace resources and settings",
"permissions": DEFAULT_ROLE_PERMISSIONS["Owner"],
"is_default": False,
"is_system_role": True,
@ -3107,7 +3087,7 @@ def get_default_roles_config() -> list[dict]:
},
{
"name": "Viewer",
"description": "Read-only access to search space resources",
"description": "Read-only access to workspace resources",
"permissions": DEFAULT_ROLE_PERMISSIONS["Viewer"],
"is_default": False,
"is_system_role": True,