First module to consume the scope layer — proves the model end-to-end
on a real query path.
Changes in calendar/queries.ts:
- db.table('calendars') → scopedForModule<LocalCalendar>('calendar', 'calendars')
- db.table('timeBlocks') → scopedForModule<LocalTimeBlock>('calendar', 'timeBlocks')
- db.table('events') → scopedForModule<LocalEvent>('calendar', 'events')
- applyVisibility() wrapper runs on each read to drop private records
authored by other members of a shared space.
Scope wrapper tweaks:
- getInScopeSpaceIds is now lenient during boot: if no active space has
loaded yet, falls back to the user's personal sentinel so sentinel-
stamped records from the v28 migration still render. Returns [] only
when fully unauthenticated, which yields an empty-match filter.
- applyVisibility is no longer generic-constrained — T is inferred
exactly as the input type; visibility/authorId are read via runtime
duck-typing so arbitrary record shapes pass through cleanly.
Known follow-ups:
- Root-layout bootstrap (load active space + reconcile sentinels on
login) is intentionally not wired up yet — needs a separate pass on
the already-crowded (app) layout to avoid collateral damage.
- Four legacy tables (conversations, documents, spaceMembers,
memoSpaces) carry a pre-existing `spaceId` field that points to the
older context-space concept, not our multi-tenancy space. Renaming
those to contextSpaceId is a tracked follow-up in the RFC — calendar
is unaffected.
Plan: docs/plans/spaces-foundation.md (updated with the legacy-spaceId
note + lenient-scope rationale).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
18 KiB
Spaces — Foundation Plan
Status (2026-04-20)
RFC / Architektur-Entscheidung, Code noch nicht begonnen. Dieses Dokument fixiert den Umbau des Scope-Modells bevor Prod-Daten existieren. Voraussetzung für: Social Relay, clubs/-Paket der ClubDesk-Roadmap, Team-/Brand-/Familien-Features generell.
Begriff gewählt: Space. Nicht Workspace (Slack/Notion-Assoziation zu stark), nicht Context (kollidiert mit kontext/-Modul), nicht Org (impliziert juristische Entität, passt nicht für Familie/Personal).
Ziel
Den User-zentrierten Daten-Scope ("jeder Record gehört implizit dem eingeloggten User") durch einen Space-zentrierten Scope ersetzen: jeder Record gehört einem Space, der User ist Mitglied eines oder mehrerer Spaces.
Kernfrage: "In welchem Kontext arbeite ich gerade, und wer sieht/darf was?" — einheitlich beantwortet für Solo-User, Familie, Verein, Brand, Team.
Damit fällt die Polymorphie User vs. Org weg, die wir in der Scope-Debatte als Hauptkomplexitätstreiber identifiziert haben. Industriestandard (Notion, Linear, Slack, Figma) aus gutem Grund.
Leitprinzipien
- Eine Primitive:
Space. Nie User vs. Org. User hat von Anfang an mindestens einen Space (personal), kann weitere haben. - Jeder Record hat
spaceId. Keine Ausnahmen. Auch Mood-Einträge, Schlafdaten, Träume leben im Personal-Space. - Ohne aktiven Space keine Query. Der Scope-Wrapper erzwingt das strukturell.
- Better Auth Organizations ist die Identity-Layer. Wir bauen auf dem Plugin, nicht daneben.
- Privat-innerhalb-Space via
visibility-Flag, nicht über Scope-Polymorphie. - Space-Typ bestimmt Modul-Sichtbarkeit. Ein Brand-Space hat kein
meditate-Modul, ein Personal-Space keinclub-finance.
Die Primitive: Space
Ein Space ist eine Better-Auth-Organization mit metadata.type.
type SpaceType =
| 'personal' // single-member, vom User selbst, existiert automatisch
| 'brand' // externe Kommunikations-Identität (Edisconet, Howspace)
| 'club' // Verein (ClubDesk-Ziel)
| 'family' // WG / Familie
| 'team' // Arbeitsteam / Projekt
| 'practice' // Freelancer-Praxis / Selbstständigkeit
type Space = {
id: string
slug: string // @edisconet, @mein-verein, @me
name: string
logo: string | null
type: SpaceType
createdAt: number
metadata: {
// Typ-spezifische Zusatzfelder — schwach typisiert, modul-spezifisch
voiceDoc?: string // brand/club
legalEntity?: string // brand/club/practice
uid?: string // brand/club/practice: MwSt / UID
aiPersonaId?: string // optional: 1:1 zu ai-agents-Eintrag
// ...
}
}
Space-Typ → Modul-Allowlist
Jeder Space-Typ schaltet einen Subset der 120+ Module frei. Nicht konfigurierbar pro Space in Phase 1 — erst wenn Bedarf auftaucht.
// packages/shared-branding/src/space-modules.ts
const SPACE_MODULES: Record<SpaceType, readonly ModuleId[]> = {
personal: [ /* alle 120+ */ ],
brand: ['social-relay', 'mail', 'landing', 'contacts', 'calendar', 'files', 'tasks', 'ai-agents', 'profile'],
club: ['club-members', 'club-finance', 'calendar', 'events', 'mail', 'landing', 'files', 'tasks', 'profile'],
family: ['calendar', 'events', 'shopping', 'recipes', 'files', 'tasks', 'finance-shared', 'profile'],
team: ['tasks', 'calendar', 'files', 'mail', 'chat', 'ai-agents', 'profile'],
practice: ['invoicing', 'contacts', 'calendar', 'mail', 'finance', 'files', 'tasks', 'profile'],
}
Data Model
Jede Tabelle, jeder Record
// alle Dexie-Tabellen + alle Postgres-Tabellen
spaceId: string // → organizations.id, NOT NULL, indexed
visibility: 'space' | 'private' // default: 'space'
authorId: string // NOT NULL, wer den Record erstellt hat
spaceId: Scope-Partition. Query startet hier.visibility: 'private': Record sichtbar nur fürauthorId, auch in Multi-Member-Spaces. Ersetzt den User-vs-Org-Gedanken für den Edge-Fall "persönlicher Draft in geteiltem Space".authorId: unabhängig vom Space-Owner — wichtig für Multi-Member-Audit ("wer hat den Eintrag angelegt").
Better-Auth-Erweiterung
Better-Auth-Plugin-Hooks:
- Signup-Hook: legt
personal-Space mit Slug@{user.username || user.id}an, setzt alsactiveOrganizationId. - Organization.create-Hook: verlangt
typeinmetadata, validiert gegen enum. - Organization.delete-Hook: verweigert Löschung, wenn
type='personal'.
JWT bleibt minimal. Laut Architektur-Entscheid 2024-12 (siehe services/mana-auth/src/auth/better-auth.config.ts:59-74) stehen Org-Claims bewusst NICHT im JWT-Payload. Der aktive Space wird über die Session (Cookie) + GET /organization/get-active-member aufgelöst. Der Scope-Wrapper (siehe unten) liest daher aus einem client-seitigen $lib/stores/active-space.svelte.ts, der beim Boot einmal den Endpoint anfragt und bei jedem set-active-Call refresht. Keine JWT-Erweiterung nötig.
Postgres-Layout
Kein eigenes spaces-Schema — wir nutzen Better-Auth's organization-Tabelle direkt. Für unsere Ergänzungen:
-- im mana_platform-DB, eigenes Schema
CREATE SCHEMA spaces;
-- Space-Credentials (OAuth-Tokens pro Space, z.B. LinkedIn für Edisconet)
CREATE TABLE spaces.credentials (
space_id TEXT NOT NULL REFERENCES organization(id) ON DELETE CASCADE,
provider TEXT NOT NULL, -- 'linkedin', 'stripe', 'twilio', ...
access_token_encrypted BYTEA NOT NULL,
refresh_token_encrypted BYTEA,
expires_at TIMESTAMPTZ,
scopes TEXT[],
created_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (space_id, provider)
);
-- Per-Modul-Permissions im Space (feingranular über Better-Auth-Rollen hinaus)
CREATE TABLE spaces.module_permissions (
space_id TEXT NOT NULL REFERENCES organization(id) ON DELETE CASCADE,
role TEXT NOT NULL, -- 'owner' | 'admin' | 'member' | custom
module_id TEXT NOT NULL, -- 'club-finance', 'social-relay', ...
can_read BOOLEAN NOT NULL DEFAULT TRUE,
can_write BOOLEAN NOT NULL DEFAULT FALSE,
can_admin BOOLEAN NOT NULL DEFAULT FALSE,
PRIMARY KEY (space_id, role, module_id)
);
mana_sync-DB
Wird um space_id-Spalte erweitert. RLS-Policy:
USING (
space_id IN (
SELECT organization_id FROM member WHERE user_id = current_user_id()
)
)
Der bestehende appId-Mechanismus bleibt — die Partition wird von (user, appId) zu (space, appId).
Scope-Wrapper (Frontend)
apps/mana/apps/web/src/lib/data/scope/
├── active-space.svelte.ts # reactive: { id, slug, type, role, permissions }
├── scoped-db.ts # Proxy um db, alle Queries bekommen spaceId-Filter
├── visibility.ts # applyVisibility(records, currentUserId)
└── migrations/
└── v27-add-space-scope.ts # Backfill alle existierenden Records → personal-Space
// Verwendung in Modulen
import { scoped } from '$lib/data/scope/scoped-db'
// statt: db.contacts.where({ ... }).toArray()
await scoped.contacts.where({ ... }).toArray()
// Escape-Hatch für Cross-Space (Home-Dashboard, globale Suche)
await scoped.contacts.acrossSpaces().where({ ... }).toArray()
Der Proxy wendet auto where({ spaceId: activeSpace.id }) und filtert visibility='private' && authorId !== me heraus.
Modul-Zugriff vor dem Wrapper
Zusätzlich zur spaceId-Filterung prüft der Wrapper gegen die SPACE_MODULES-Allowlist:
// scoped.club-finance in einem Personal-Space → wirft ModuleNotInSpaceError
// scoped.meditate in einem Brand-Space → dito
Das verhindert, dass ein Modul, das UI-seitig eigentlich ausgeblendet ist, über direkte Store-Calls trotzdem Daten schreibt.
URL-Topologie
Slack/Linear/Notion-Muster:
mana.how/@{slug}/{module}/...
mana.how/@me/mood/today ← Personal-Space
mana.how/@edisconet/social-relay ← Brand-Space
mana.how/@turnverein/club-finance ← Club-Space
- SvelteKit:
+layout.tsauf/(app)/@[slug]/+layout.tsresolved Space über Better-Auth-Slug-API - Middleware: Non-Member → 404 (nicht 403, um Space-Existenz nicht zu leaken)
- Altes
activeOrganizationIdin Session wird gesetzt, damit Requests gegenapps/apiden Scope kennen - Space-Switcher-UI: klickt auf Space →
goto('/@${newSlug}/home')statt Session-Manipulation
Migration bestehender Routes: alle Routes von /(app)/{module} auf /(app)/@[slug]/{module} verschieben. @me als Auto-Redirect für die alten URLs.
Encryption
Drei-Stufen-Modell ersetzt die heutige User-Key-Logik:
- Personal-Space: User-Key wie heute (master-key aus
mana-auth,MANA_AUTH_KEK-gewrappt). Zero-Knowledge bleibt möglich. - Shared-Space (>1 Member): Space-Key, beim Space-Create erzeugt, KEK-gewrappt an jedes Mitglied über dessen Pubkey. Re-Wrap bei Member-Add. Member-Remove = Key-Rotation-Event (neue Keys für neue Records; alte bleiben lesbar — "eventual re-encryption" als Background-Job).
- Private Records innerhalb eines Shared-Space (
visibility='private'): zusätzlich mit User-Key re-encrypted, nur Autor kann lesen.
Die Encryption-Registry (apps/mana/apps/web/src/lib/data/crypto/registry.ts) muss für jede Tabelle wissen, welche Felder verschlüsselt werden — das bleibt wie heute. Was sich ändert: encryptRecord() wählt den Key aufgrund von (spaceId, visibility) statt konstant den User-Key.
Phase-1-Kompromiss: Shared-Space-Daten initial unverschlüsselt lassen (nur im Server-RLS-gesichert). Zero-Knowledge für Shared-Spaces kommt als eigenes RFC (docs/plans/shared-space-encryption.md), weil Key-Rotation und Pubkey-Infrastruktur für User nicht-trivial sind. Personal-Space behält E2E-Encryption wie heute.
Tier-Logik verschieben
Heute: tier hängt am User (JWT-Claim). Das führt zum aktuellen MANA_APPS tier patch-Workaround.
Neu: Tier hängt am Space.
// space.metadata.tier: 'guest' | 'public' | 'beta' | 'alpha' | 'founder'
// Modul-Gate: requiredTier wird gegen activeSpace.tier geprüft
- Personal-Space eines Founder-Users →
tier='founder', alle Module - Ein Brand-Space, den derselbe User anlegt → default
tier='beta', je nach Plan - Admin-API
PUT /api/v1/admin/spaces/:id/tierersetzt das alte User-Tier-API
Das löst den bestehenden Tier-Patch-Workaround gleich mit auf und passt zum Billing-Modell der ClubDesk-Roadmap (Vereine zahlen pro Space, nicht User).
Sync-Engine-Änderung
mana-sync (Go, 3050) bekommt:
space_id-Column in allen sync-relevanten Tabellen — NOT NULL, indexed.- Partition-Key wird
(space_id, app_id)statt(user_id, app_id). - Subscription-Fan-Out: bei Space-Member-Count > 1 muss ein Change an alle Member gepusht werden (WS/SSE). Heute ist das 1:1 (User pushed to self).
- Membership-Resolver: der Server muss bei jeder Client-Connection wissen, welche Space-IDs der User sehen darf — aus Better-Auth's
member-Tabelle.
Keine großen Algorithmus-Änderungen am Field-Level-LWW — der Scope partitioniert nur den Keyspace, die Konflikt-Resolution bleibt identisch.
Migration (alles vor dem ersten Prod-Launch)
Phase 1 — Schema + Better Auth (3–4 Tage)
metadata.typezu Better-Auth-Organization hinzufügen, Enum-Validierung im Signup-Hookspaces.credentials+spaces.module_permissionsTabellen anlegen- Signup-Hook schreibt Personal-Space mit Slug
@{uniqueUsername} - Dexie v28 (v27 ist vergeben):
spaceId,visibility,authorIdauf alle Tabellen - Migration-Script: alle bestehenden Records bekommen
spaceId=personalSpaceId(authorId),visibility='space',authorId=recordUserId SPACE_MODULESAllowlist inpackages/shared-branding
Phase 2 — Scope-Wrapper + eines Pilot-Modul (3–4 Tage)
active-space.svelte.ts,scoped-db.ts,visibility.tscalendar/als Pilot umstellen: alle Queries aufscoped.events...- Space-Switcher-Komponente in der Shell
/@[slug]/Routing-Shell
Phase 3 — URL-Refactor + Module-Rolling-Migration (1–2 Wochen)
- Alle
(app)/*-Routen nach(app)/@[slug]/*verschieben - Redirects
/(old-route)→/@me/(old-route)als Übergang (1 Release, dann entfernen) - Modul-für-Modul auf
scoped-Wrapper umstellen — kann parallelisiert werden - Tier-Logik von User auf Space verschieben;
MANA_APPS tier patchauflösen
Phase 4 — mana-sync (2–3 Tage)
space_id-Column + RLS-Policy- Partition-Key auf
(space_id, app_id) - Subscription-Fan-Out testen mit 2-Member-Spaces
- camt-/TWINT-Integration des ClubDesk-Pakets B baut dann direkt auf Space-Scope auf
Phase 5 — Shared-Space-Encryption (eigenes RFC, späterer Zeitpunkt)
Siehe Encryption-Abschnitt. Nicht blockierend für Launch, kommt bei Bedarf.
Konsequenzen / neue Anforderungen
Space-Create-Flow
UI-Flow "Neuen Space anlegen" muss Typ-Auswahl anbieten mit Kurzbeschreibung. Templates (z.B. "Verein" → lädt club-Grundstruktur) wären wünschenswert, aber Phase 2.
Cross-Space-Dashboards
- Home (
/@me/home): aggregiert "heute anstehend" aus allen Spaces, die der User sieht. Nutztscoped.X.acrossSpaces(). - Globale Suche: Default = aktiver Space. Toggle "alle Spaces" sichtbar.
- Notification-Feed: immer Space-tagged (
[Edisconet] ...,[Turnverein] ...).
Space-Discovery / Invite-Flow
Better Auth liefert den Invite-Token-Flow. UI: Share-Link mit Rolle, E-Mail-Invite mit Rolle, Join-Page mit Space-Preview vor Accept.
Profile-Modul wird bi-skopig
Das bestehende profile/-Modul bekommt zwei Modi: "User-Profil" (persönliche Daten, Timezone, Sprache) und "Space-Profil" (Logo, Voice-Doc, legal entity, Tools). Beide leben im aktiven Space — Space vom Typ personal zeigt User-Profil-Variante, andere Typen zeigen Space-Profil-Variante.
Module, die fachlich nicht teilbar sind
Via SPACE_MODULES in Nicht-Personal-Spaces gar nicht erst erreichbar. Kein Code im Modul selber nötig.
Was NICHT in diesem RFC entschieden wird
- Konkrete ClubDesk-Module (
clubs/,club-finance): folgen einem eigenen Plan, bauen aber auf Spaces auf - Social-Relay-Implementierung: der bestehende
social-relay-module.mdwird nach diesem Foundation-Plan überarbeitet (Brand wird zum Space, nicht eigenesbrands/-Modul) - Shared-Space-Encryption: eigenes RFC (Phase 5)
- Billing-Integration (Stripe pro Space): späterer Plan, braucht Spaces als Voraussetzung
- Multi-Space-Parallel-Views (Slack-Stil mit mehreren Spaces gleichzeitig sichtbar): Phase-2-Idee, nicht Launch-kritisch
Bekannte Altlast: spaceId-Namenskollision
Vier bestehende Dexie-Tabellen nutzen das Feld spaceId bereits für das
ältere Context-Space-Konzept (chat-/memoro-interne Kontext-Ordner,
nicht das neue Multi-Tenancy-Space):
conversations(chat) —spaceId→contextSpaces.iddocuments(context) —spaceId→contextSpaces.idspaceMembers(memoro) —spaceId→contextSpaces.idmemoSpaces(memoro) —spaceId→contextSpaces.id
Die v28-Migration hat diese Tabellen nicht korrumpiert, weil der
Stamping-Code nur fehlende spaceId-Felder setzt (if undefined/null).
Bestehende Records mit Context-Space-Referenzen sind unverändert.
Follow-up: Rename spaceId → contextSpaceId auf diesen vier Tabellen
- ihren Modulen + Dexie-v29-Migration, damit das Namensfeld eindeutig der neuen Space-Primitive gehört. Bis dahin ist der Scope-Wrapper für diese Tabellen nicht verwendbar — entweder Kollision erst fixen oder das Wrapper-Filter per Modul-Ausnahme deaktivieren. Calendar, Todo, Notes etc. sind nicht betroffen.
Offene Fragen
- Slug-Uniqueness-Kollision: User Till mit
@tillkollidiert mit potentiellem Brand@till. Lösungsraum: Slugs global unique (einfach, aber Race um beliebte Namen) vs. Slug-Präfixe (@user/tillvs.@org/till— hässlich). Vorschlag: global unique, First-Come-First-Served, User-Slug bei Signup aus E-Mail-Local-Part + Suffix bei Kollision. - Default-Visibility neuer Felder:
visibility='space'standard. Aber z.B. Mood-Einträge in einem Shared-Family-Space wärenvisibility='private'per Modul-Config sinnvoll. Brauchen wir eindefaultVisibilitypro Modul-Tabelle? - Space-Löschung: was passiert mit veröffentlichten Inhalten (z.B. LinkedIn-Posts via Social Relay)? Soft-Delete mit Re-Activation-Period? Hard-Delete mit Warnungen? Orga-Entscheidung, nicht Architektur.
- Anonymes Space-Sharing (z.B. Event-Anmelde-Link ohne Account — existiert bereits in
mana-events): bleibt wie heute, ist public-by-token und orthogonal zum Space-Scope. - Werden User-Tables in Better-Auth selbst ge-space't? Nein: User, Session, Account sind identity-global. Nur unsere Domain-Daten werden ge-space't.
- Slug-Änderungen: darf ein Space seinen Slug ändern? Ja, mit 301-Redirect über alte Slugs (braucht
space_slugs_history-Tabelle). Phase 2. - Welches Modul bekommt
social-relayzum ersten Einsatz? Logisch: nachdem Spaces stehen, Social Relay als erster neuer Modul-Code nach der Foundation — das stress-testet das ganze Modell mit einer echten Brand-Space-Anwendung.
Zusammenfassung
Space-first macht 120+ Module strukturell gleich, eliminiert die User-vs-Org-Polymorphie, nutzt Better Auth maximal, und bildet die Grundlage für alles, was mit Team-/Brand-/Verein-Features kommt. Der Aufwand ist ein einmaliger Schema-Umbau jetzt (geschätzt 3–4 Wochen bei konzentrierter Arbeit), bevor Prod-Daten existieren. Das Alternative (später nachrüsten) wäre ein mehrmonatiges Migrationsprojekt mit Downtime.
Reihenfolge kritischer Abhängigkeiten:
- Phase 1 (Schema) — blockiert alles weitere
- Phase 2 (Wrapper + Pilot) — validiert das Modell an einem echten Modul
- Phase 3 + 4 (Rolling-Migration) — parallelisierbar
- Social Relay, ClubDesk-Pakete, Billing, Team-Features — alle danach