Data groups: a provider's models read only their own group's data

Every connection is in a data group. Its models are handed, and can find,
only that group's memories, notes, skills, knowledge, reports and
personality -- by search and by id. A chat stays in the group it was
started in: switching its model, the endpoint fallback, the crowd, friends,
bases and the @ menu all stay inside it, and a chat whose model has moved
is refused rather than sent. A group may name its own embedder and image
reviewer. data.manage lets a person make personal groups, remap
connections for themselves and move their own records.

Also: a search no longer mixes two embedders of the same width.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-29 16:07:55 +00:00
co-authored by Claude Opus 5.5
parent e65ea90fe6
commit 9970bb43c6
61 changed files with 3595 additions and 200 deletions
+41 -7
View File
@@ -34,9 +34,9 @@ from lembas.security import permissions
from lembas.services import audio as audio_service
from lembas.services import chat as chat_service
from lembas.services import compaction as compaction_service
from lembas.services import data_groups, interaction, settings_store, sse
from lembas.services import files as files_service
from lembas.services import generation as generation_service
from lembas.services import interaction, settings_store, sse
from lembas.services import metrics as metrics_service
from lembas.services import prompts as prompts_service
from lembas.services import reports as reports_service
@@ -222,6 +222,14 @@ def _new_chat(
folder_id=folder.id if folder is not None else None,
model_id=chosen[0] if chosen else "",
connection_id=chosen[1] if chosen else None,
# The chat's data group is its model's, fixed now. It is what every
# later turn reads memories and notes from, and what decides which models
# this chat may be switched to -- see services/data_groups.py.
data_group_id=(
data_groups.for_pair(db, user, chosen[0], chosen[1])
if chosen
else data_groups.DEFAULT_GROUP
),
temporary=temporary,
kind=KIND_AGENT if profile is not None else KIND_CHAT,
ssh_profile_id=profile.id if profile is not None else None,
@@ -657,8 +665,12 @@ async def attach_base(
if not permissions.has(db, user, "library.use"):
raise HTTPException(status.HTTP_403_FORBIDDEN, "You may not use the library.")
# Only a base in the chat's own data group: its documents are what this
# chat's model would search, and another group's are not its to read.
base = db.scalar(
documents_service.visible_bases(db, user).where(KnowledgeBase.id == base_id)
documents_service.visible_bases(db, user, data_groups.for_chat(db, chat)).where(
KnowledgeBase.id == base_id
)
)
if base is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "That knowledge base is not available.")
@@ -1536,8 +1548,13 @@ def _apply_crowd(db: DBSession, chat: Chat, user: User, values: list[str]) -> No
from lembas.db.models import CrowdMember
settings = settings_store.crowd(db)
# Only models in the chat's own data group: a member is sent the whole
# conversation, so one from another group would carry it to that provider.
reachable = {
model.model_id: model for model in chat_service.available_models(db, user)
model.model_id: model
for model in chat_service.available_models(
db, user, data_groups.for_chat(db, chat)
)
}
wanted: list[str] = []
for value in values:
@@ -2125,11 +2142,28 @@ async def update_chat(request: Request, db: Db, user: RequiredUser, chat_id: str
)
# Checked against what this user can reach, not merely what exists --
# otherwise the picker is advisory and a crafted request bypasses it.
# And within the chat's own data group: the new model would be sent the
# whole history, which is exactly what a group keeps from its provider.
group = data_groups.for_chat(db, chat)
match = next(
(m for m in chat_service.available_models(db, user) if m.model_id == model_id),
(
m
for m in chat_service.available_models(db, user, group)
if m.model_id == model_id
),
None,
)
if match is None:
elsewhere = any(
m.model_id == model_id for m in chat_service.available_models(db, user)
)
if elsewhere:
raise HTTPException(
status.HTTP_409_CONFLICT,
f"That model is in another data group than this chat "
f"({data_groups.name_of(db, group)}), so it cannot be given this "
f"chat's history. Start a new chat with it instead.",
)
raise HTTPException(status.HTTP_403_FORBIDDEN, "That model is not available to you.")
chat.model_id = model_id
chat.connection_id = match.connection_id
@@ -2152,9 +2186,9 @@ async def update_chat(request: Request, db: Db, user: RequiredUser, chat_id: str
chat.knowledge_bases = (
list(
db.scalars(
documents_service.visible_bases(db, user).where(
KnowledgeBase.id.in_(wanted)
)
documents_service.visible_bases(
db, user, data_groups.for_chat(db, chat)
).where(KnowledgeBase.id.in_(wanted))
)
)
if wanted