# Space-scoped data model (Modell β) _Started 2026-04-22._ _Supersedes [`per-space-vs-user-global-tags.md`](./per-space-vs-user-global-tags.md) — the earlier "defer" recommendation was made under "ship fast" assumptions; this plan assumes pre-live + unlimited resources, i.e. we build the clean architecture now without legacy residues._ ## Decision Everything that the user creates — tags, tag-groups, workbench scenes, AI agents, AI missions, kontextDoc — **lives in a Space.** Only identity, authentication, session, profile, master-key material, and per-device UI preferences stay at the user level. Two crisp levels: | Level | What lives here | Examples | | --- | --- | --- | | **User** | Identity · auth · MK key · cross-device profile · preferences that follow the user · **tag presets** (user-level templates for new Spaces) | `user`, `session`, `authKeys`, `profile`, `userSettings`, `userTagPresets` | | **Space** | **Every** data record the user creates or interacts with | Tasks · events · notes · contacts · dreams · memoros · tags · tag-groups · scenes · agents · missions · **kontextDoc** · workbench layouts — everything | | **Device** | Ephemeral UI state that's explicitly per-device | Active scene per Space · theme toggle · panel sizes · last-used module — all via localStorage | "Device" isn't a data model — it's localStorage conventions. Listed for completeness so nothing falls through the cracks. ### Why β, not γ (recursive Context) - β has **two clear data levels** — Space (tenant) vs. Scene-as-filter (view inside that Space). Users always know where they are. - γ (one recursive `Context` primitive with arbitrary nesting) is more flexible but mentally heavier — "am I in a Space or a sub-Space?" is a real question users would have to answer. - β is a natural stepping stone if we ever want γ: a γ-Context is just a β-Space with children. ## No legacy residues Explicit anti-patterns this plan rejects (so we don't drift back into them): 1. **No `userId` audit column on data records.** Attribution lives in the Actor system (`__lastActor`, `__fieldActors` — added during the AI-Workbench rollout) on every record. A separate `userId` would be redundant. Members of a Space see each others' Actor attributions; that's the correct model for shared-Space collaboration. 2. **No "user-global tag" hybrid.** Tags *always* belong to a Space. Users who want a taxonomy to propagate across their Spaces create a **tag preset** at user level (see §5) and seed new Spaces from it. 3. **No single-ID `activeSceneId` in localStorage.** The active scene is per-Space-per-device; the key is `mana:workbench:activeSceneId:${spaceId}`. 4. **No plaintext-tag defer.** User-typed tag names (`Therapie`, `Finanzen-privat`) can leak personal categorization. Encrypt during the migration, not as a follow-up. 5. **No "one default agent everywhere" name collision.** Default-agent bootstrap uses a SpaceType-aware name (see §4). 6. **No user-level `kontextDoc` singleton.** The AI planner's auto- injected context is per-Space (a Family-Space doesn't want to see the user's Brand-Space bio). ## Scope ### In scope (become Space-scoped) - `globalTags` + `tagGroups` — add `spaceId`, drop `userId`, encrypt `name` + `icon`. - `workbenchScenes` — add `spaceId`. Layout + `scopeTagIds` stay. - `aiAgents` — add `spaceId`. Bootstrap runs per Space. - `aiMissions` — add `spaceId`. Follows agents. - `kontextDoc` (the planner-injected user bio singleton) — becomes per-Space: each Space has at most one `kontextDoc`. Keyed off `[spaceId, type: 'kontextDoc']`. - Anything else Phase 1's audit turns up. ### Stays user-level (identity / preferences) - `user`, `session`, `authKeys`, MK key wrapping - `profile` (bio, avatar, locale, timezone) - `userSettings` — user-wide prefs (theme default, locale) - `userTagPresets` — **new table** (see §5) - Access-tier claim on the JWT ### Ephemeral, per-device (localStorage only) - Active scene per Space: `mana:workbench:activeSceneId:${spaceId}` - Active space hint: `mana:scope.activeSpaceId` (already exists) - Theme toggle, panel sizes, last-used module, debug flags ### Already correctly Space-scoped The ~46 module tables migrated in the Spaces-Foundation sprint. Nothing to do. ### Junction tables — must be audited in Phase 1 All 19 tag junction tables (`taskLabels`, `eventTags`, `contactTags`, …) are FK-only and inherit `spaceId` from their parent record. Phase 1 confirms that **every** junction row's parent actually carries `spaceId` — any junction referencing a still-user-scoped table is a migration bug. Full list generated via codebase audit, not assumed. ## Target schemas ### `globalTags` ```ts interface LocalTag { id: string; spaceId: string; // NEW — required, indexed name: string; // encrypted (see Phase 2) color: string; icon?: string; // encrypted groupId?: string; sortOrder: number; createdAt: string; updatedAt: string; deletedAt?: string; __fieldTimestamps?: Record; __lastActor?: Actor; // from AI-Workbench rollout __fieldActors?: Record; // NO userId } ``` Dexie indexes: `id, spaceId, [spaceId+name], [spaceId+sortOrder]`. Crypto registry (`apps/mana/apps/web/src/lib/data/crypto/registry.ts`): ```ts globalTags: { encrypted: ['name', 'icon'], plaintext: ['id', 'spaceId', 'color', 'groupId', 'sortOrder', 'createdAt', 'updatedAt', 'deletedAt', '__fieldTimestamps', '__lastActor', '__fieldActors'], } ``` ### `tagGroups` Same treatment. `spaceId` required, `name` encrypted. ### `workbenchScenes` Already has `title`, layout, `scopeTagIds?`. Add `spaceId`. Encrypt `title` (it's user-authored). ### `aiAgents`, `aiMissions` `spaceId` required. Names/system-prompt/memory/mission-objective are already encrypted (per AI-Workbench rollout). Nothing changes on the crypto side except adding a `spaceId` plaintext column to the registry entries. ### `kontextDoc` Today a user-level singleton. New shape: ```ts interface LocalKontextDoc { id: string; // uuid, one per Space spaceId: string; // required, unique in-space type: 'kontextDoc'; // discriminator, for the [spaceId+type] unique index content: string; // encrypted (already is) updatedAt: string; __lastActor?: Actor; __fieldActors?: Record; } ``` Dexie indexes: `id, spaceId, [spaceId+type]`. The AI runner's auto-injection logic switches from `getUserKontextDoc()` to `getKontextDocForActiveSpace()`. ### `userTagPresets` (NEW — user-level) ```ts interface LocalUserTagPreset { id: string; userId: string; // explicit — this table is user-scoped name: string; // "Mein Standard-Set", "Brand-Setup", … isDefault: boolean; // at most one default per user tags: Array<{ name: string; color: string; icon?: string; groupName?: string; }>; createdAt: string; updatedAt: string; } ``` This is a **first-class template** for seeding new Spaces, not a live link. Values are frozen snapshots of tag definitions. When creating a new Space, the user picks a preset (or none); tags from the preset are one-shot-copied into the new Space with fresh UUIDs. Lives outside any Space in the Dexie DB. Synced cross-device per- user. Encrypted same as `globalTags` (tag names inside are sensitive). ## Phases ### Phase 1 — Audit + schema finalisation (0.5 day) - Enumerate every Dexie `store(...)` definition in `apps/mana/apps/web/src/lib/data/database.ts`. Cross-check against `packages/shared-types`. - For every table, decide: - **Space-scoped** (majority — migrate) - **User-level** (only identity / profile / preferences / `userTagPresets`) - **Junction** (inherits from parent — verify parent has `spaceId`) - Verify every junction row's parent is Space-scoped. Any junction still pointing at a user-global table is a bug to flag. - Verify Actor columns (`__lastActor`, `__fieldActors`) are on every migration target — so we can confidently drop `userId`. - Deliverable: concrete table list in an appendix to this doc, committed before Phase 2 runs. Any surprises get called out. ### Phase 2 — Dexie migration (1.5 days) - Bump Dexie version. Write migration function that for each target table: 1. Adds `spaceId = user.personalSpaceId` (signup hook guarantees it exists). 2. **Drops `userId`** — attribution is the Actor system's job. (`__lastActor` is already populated for records created after the AI-Workbench rollout; pre-rollout rows get `__lastActor = { kind: 'user', principalId: userId, displayName: user.name }` during the migration — a one-time backfill, then `userId` is deleted). 3. For `globalTags` / `tagGroups` / `workbenchScenes`: encrypt `name` / `title` / `icon` during migration. Register in crypto registry. Dev-mode drift check catches missed fields. 4. For `kontextDoc`: reshape into `{ id, spaceId, type: 'kontextDoc', content, ... }`. Each user has exactly one pre-migration kontextDoc — it becomes the Personal-Space's kontextDoc. Other Spaces get no kontextDoc until the user writes one. - Register new tables with `scopedForModule()`: `scopedForModule('tags', 'globalTags')`, `scopedForModule('workbench', 'workbenchScenes')`, `scopedForModule('ai', 'aiAgents')`, `scopedForModule('ai', 'aiMissions')`, `scopedForModule('ai', 'kontextDoc')`. - Create the new `userTagPresets` table — user-level, encrypted `name` + inline tag names. - `database.ts` creating hook: stamp `spaceId` from `getActiveSpaceId()`. Throw `ScopeNotReadyError` if unset — guards against races. - Remove `userId` stamping for Space-scoped tables (the creating hook stops stamping it since the column is gone). - Unit tests (fake-indexeddb): round-trip a tag through encrypt/write/read in two different Spaces, verify isolation. Round-trip a preset apply, verify tags land with correct spaceId + fresh ids. ### Phase 3 — Store APIs + runner integration (1 day) - `packages/shared-stores/src/tags-local.svelte.ts`: - `useAllTags()` — unchanged surface; implicit Space filter from `scopedForModule()`. Returned tags have `name` already decrypted (crypto pipeline). - `tagMutations.createTag(input)` — no `spaceId` argument; hook stamps it. Refuses explicit cross-Space writes. - `userTagPresets` store: `useTagPresets()`, `createPreset()`, `applyPresetToSpace(presetId, spaceId)` — the apply flow one-shot-copies preset entries into the target Space as fresh `globalTags` rows. - `workbenchScenesStore`: - `useScenes()` → Space-filtered live query. - Per-Space default scene bootstrap (see Phase 4). - AI agents store: - `bootstrapDefaultAgent(spaceId, spaceType)` — idempotent, keyed `[spaceId, kind: 'default']`. Name derived from `spaceType`: - `personal` → "Mana" - `family` → "Familien-Helfer" - `team` → "Team-Assistent" - `brand` → "Brand-Assistent" - `club` → "Verein-Helfer" - `practice` → "Praxis-Assistent" - Users rename freely after bootstrap. - AI runner (`src/lib/data/ai/missions/runner.ts`): - Replace `getUserKontextDoc()` with `getKontextDocForActiveSpace()`. If none exists for the active Space, skip injection (not an error — users opt in by writing one). ### Phase 4 — Space-switch behavior (0.5 day) `apps/mana/apps/web/src/lib/data/scope/active-space.svelte.ts`: After `loadActiveSpace()` completes and sets `active = space`, fire `onActiveSpaceChanged(space)`: 1. **Bootstrap singletons if missing** (idempotent): - Default agent for this Space's type (see §3). - Default scene "Übersicht" with empty layout. 2. **Restore per-device active scene** for this Space from `localStorage.getItem('mana:workbench:activeSceneId:' + space.id)`. If null or stale, fall back to the Space's default scene. 3. **No data reset** — all reactive queries already pivot via `scopedForModule()` on the new `activeSpaceId`. Module stores don't need custom reset logic. 4. **Per-device UI state is preserved** — theme, panel sizes, last-used module id don't change. `workbenchScenesStore.setActiveScene(sceneId)` writes the per-Space localStorage key, not the old single-key one. ### Phase 5 — Space creation + preset application (0.5 day) `apps/mana/apps/web/src/lib/components/layout/SpaceCreateDialog.svelte`: Add a "Tag-Set" section to the create dialog: - Dropdown of the user's `userTagPresets` (if any), plus built-in options: "Leer" (default for Shared-Spaces) and "Aus Personal kopieren" (default for Solo-Spaces — a convenience shortcut that copies the Personal-Space's current tags as a one-shot). - On submit: create Space → bootstrap default agent + default scene → if a preset / copy-from-Personal was chosen, one-shot-copy tags. Admin-style entry in the user's Settings → "Tag-Presets" to create/edit presets (CRUD for `userTagPresets`). Any existing Space's tag set can be "exported as preset" from its TagManager UI. ### Phase 6 — Backend (mana-sync + Postgres + RLS) (1 day) - PostgreSQL migration: add `space_id text not null` on sync tables for `globalTags`, `tag_groups`, `workbench_scenes`, `ai_agents`, `ai_missions`, `kontext_docs`. Drop `user_id` from the first four (keep only on the new `user_tag_presets` table, which remains user-scoped). - Two-step Postgres backfill: add nullable `space_id`, backfill via join (`update … set space_id = personal_space_of(user_id)`), then `set not null`, then drop `user_id`. - RLS policies: membership-based for every Space-scoped table (matches existing 46-table pattern). - `user_tag_presets`: RLS = `user_id = current_user_id()`. - mana-sync config: register new Space-scoped tables in the collection→space-table mapping; register `user_tag_presets` in the user-scoped mapping. ### Phase 7 — Cleanup + docs (0.5 day) - Delete the `// TODO: audit` encryption comment on `globalTags` — resolved. - Update `apps/mana/apps/web/src/lib/data/DATA_LAYER_AUDIT.md`: canonical rule is now "data records carry `spaceId`, not `userId`; attribution lives in Actor columns". Document `userTagPresets` as the only user-scoped data table. - Add a section to `apps/mana/CLAUDE.md`: > Adding a new top-level table? It gets `spaceId`, not `userId`. > User-level is reserved for identity, auth, profile, and > explicit user-level templates (like `userTagPresets`). > Attribution on every record is via `__lastActor` / `__fieldActors`. - Update memory files: - `feedback_cards_over_subroutes.md` — note that tags, scenes, agents, missions, kontextDoc are all Space-scoped. - Add `project_space_scoped_datamodel.md` with commit refs after shipping. - Regenerate `validate:all` outputs — the three validation scripts (turbo recursion, pgSchema, crypto registry) should all stay green since Phase 2 wired the new tables in from the start. ### Phase 8 — Delete the deprecated plan (5 min) `per-space-vs-user-global-tags.md` gets deleted in the same PR as Phase 7. Git history preserves the deferred-decision reasoning for future readers who dig; the current tree shouldn't confuse anyone. ## Edge cases & decisions (baked in) **Member of another user's Shared-Space at migration time?** Existing user-global tags/scenes/agents go into **your** Personal-Space. Shared Spaces start empty from your side; other members' content is visible via RLS once you join. **Signup hook failed — no Personal-Space?** Phase 2 migration refuses to run. Repair that user's state first, then retry. **`kontextDoc` in non-Personal Spaces?** Absent by default. User writes one explicitly if the planner should inject Space-specific context. UI exposes "Space-Kontext bearbeiten" in each Space's Settings. **Default agent name collision across user's Spaces?** Names are SpaceType-aware, so three Spaces give three different defaults. Users can rename freely — the bootstrap only runs once per Space. **What if a user deletes their only `userTagPreset`?** That's fine — new-Space dialog falls back to "Leer" / "Aus Personal kopieren". Presets are a convenience, not a dependency. **Cross-device preset drift?** `userTagPresets` syncs via mana-sync with user-level RLS. Same model as `profile`. ## Success criteria Before merging: - [ ] No table in the Dexie DB has both `userId` and `spaceId` columns (the canonical rule is one-or-the-other, based on the Space-scoped vs. user-level decision). - [ ] `validate:all` green: turbo recursion · pgSchema · crypto registry · theme tokens · CSS utilities. - [ ] Switching active Space in the UI flips: tag list · scene list · agent list · mission list · kontextDoc — all at once, no stale state in any module. - [ ] Creating a new Shared-Space starts with an empty tag list. Creating a new Solo-Space offers the preset picker with user-defined options + the "Aus Personal kopieren" shortcut. - [ ] Active scene persists per-Space-per-device: switch Space A → scene X, switch to Space B → scene Y, switch back to A → scene X restored. - [ ] AI runner in Family-Space injects the Family-Space `kontextDoc`, not Personal's. - [ ] Default agents bootstrap with SpaceType-aware names — three Spaces of different types show three distinct default agents. - [ ] Tag names are encrypted at rest (verified via direct Dexie inspection showing ciphertext for `name` / `icon`). - [ ] Smoke: in Personal-Space, create tag "Urgent". Switch to a new Shared-Space. `useAllTags()` returns empty. Switch back. "Urgent" visible. Create preset from Personal. Create new Brand-Space with that preset. "Urgent" appears there (with a different `tag.id`, confirming it's a copy not a reference). ## Timeline ~4–5 focused days across all phases (up from 3–4 in the previous draft; the extra day covers encryption wiring, `userTagPresets` CRUD, and the `userId` → Actor attribution cleanup). ## Related - [`spaces-foundation.md`](./spaces-foundation.md) — the Spaces primitive this plan extends. - [`workbench-cards-migration.md`](./workbench-cards-migration.md) — the cards-vs-routes policy from the same strategic session. - [`scene-scope-empty-state.md`](./scene-scope-empty-state.md) — UX for the scope-filter empty state; integrates naturally once scenes are per-Space. - **Deprecated** by this doc: [`per-space-vs-user-global-tags.md`](./per-space-vs-user-global-tags.md) — delete in Phase 8.