Self-audit of the previous draft surfaced 7 legacy residues that would
have left the rebuild short of the "optimal architecture" bar. Rewrite
the plan with those addressed:
1. Drop userId from data records entirely. Attribution lives in the
Actor system (__lastActor / __fieldActors). userId stays only on
explicitly user-scoped tables.
2. Active scene localStorage key becomes per-Space:
`mana:workbench:activeSceneId:${spaceId}` — switch Space A → scene
X, to B → scene Y, back to A → X restored.
3. New user-level userTagPresets table replaces the "copy from
Personal" checkbox hack. First-class templates for seeding new
Spaces with a named tag set; CRUD in Settings.
4. Encryption decision made in-line: globalTags + tagGroups names
encrypted during migration, not deferred (tag names like
"Therapie" or "Finanzen-privat" can leak personal categorization).
5. kontextDoc moves from user-level singleton to per-Space. AI runner
pulls the active Space's kontextDoc; Shared-Spaces start without
one until the user writes one.
6. Default-agent bootstrap uses SpaceType-aware names (Mana for
personal, Familien-Helfer for family, Team-Assistent for team,
etc.) so users don't end up with "three Mana" in their agent list.
7. Phase 1 explicitly audits every junction table to verify parent
records carry spaceId — no silent user-global references.
Also: an explicit "No legacy residues" section anchors these as
intentional anti-patterns to prevent drift. Success criteria now
includes "no table has both userId AND spaceId" as a testable
invariant.
Timeline grows from 3–4 to 4–5 days; the delta is encryption wiring
+ userTagPresets CRUD + the userId→Actor cleanup.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
18 KiB
Space-scoped data model (Modell β)
Started 2026-04-22.
Supersedes 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
Contextprimitive 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):
- No
userIdaudit column on data records. Attribution lives in the Actor system (__lastActor,__fieldActors— added during the AI-Workbench rollout) on every record. A separateuserIdwould be redundant. Members of a Space see each others' Actor attributions; that's the correct model for shared-Space collaboration. - 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.
- No single-ID
activeSceneIdin localStorage. The active scene is per-Space-per-device; the key ismana:workbench:activeSceneId:${spaceId}. - No plaintext-tag defer. User-typed tag names (
Therapie,Finanzen-privat) can leak personal categorization. Encrypt during the migration, not as a follow-up. - No "one default agent everywhere" name collision. Default-agent bootstrap uses a SpaceType-aware name (see §4).
- No user-level
kontextDocsingleton. 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— addspaceId, dropuserId, encryptname+icon.workbenchScenes— addspaceId. Layout +scopeTagIdsstay.aiAgents— addspaceId. Bootstrap runs per Space.aiMissions— addspaceId. Follows agents.kontextDoc(the planner-injected user bio singleton) — becomes per-Space: each Space has at most onekontextDoc. Keyed off[spaceId, type: 'kontextDoc'].- Anything else Phase 1's audit turns up.
Stays user-level (identity / preferences)
user,session,authKeys, MK key wrappingprofile(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
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<string, number>;
__lastActor?: Actor; // from AI-Workbench rollout
__fieldActors?: Record<string, Actor>;
// NO userId
}
Dexie indexes: id, spaceId, [spaceId+name], [spaceId+sortOrder].
Crypto registry (apps/mana/apps/web/src/lib/data/crypto/registry.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:
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<string, Actor>;
}
Dexie indexes: id, spaceId, [spaceId+type].
The AI runner's auto-injection logic switches from
getUserKontextDoc() to getKontextDocForActiveSpace().
userTagPresets (NEW — user-level)
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 inapps/mana/apps/web/src/lib/data/database.ts. Cross-check againstpackages/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 dropuserId. - 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:
- Adds
spaceId = user.personalSpaceId(signup hook guarantees it exists). - Drops
userId— attribution is the Actor system's job. (__lastActoris 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, thenuserIdis deleted). - For
globalTags/tagGroups/workbenchScenes: encryptname/title/iconduring migration. Register in crypto registry. Dev-mode drift check catches missed fields. - 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.
- Adds
- Register new tables with
scopedForModule():scopedForModule('tags', 'globalTags'),scopedForModule('workbench', 'workbenchScenes'),scopedForModule('ai', 'aiAgents'),scopedForModule('ai', 'aiMissions'),scopedForModule('ai', 'kontextDoc'). - Create the new
userTagPresetstable — user-level, encryptedname+ inline tag names. database.tscreating hook: stampspaceIdfromgetActiveSpaceId(). ThrowScopeNotReadyErrorif unset — guards against races.- Remove
userIdstamping 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 fromscopedForModule(). Returned tags havenamealready decrypted (crypto pipeline).tagMutations.createTag(input)— nospaceIdargument; hook stamps it. Refuses explicit cross-Space writes.
userTagPresetsstore:useTagPresets(),createPreset(),applyPresetToSpace(presetId, spaceId)— the apply flow one-shot-copies preset entries into the target Space as freshglobalTagsrows.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 fromspaceType: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()withgetKontextDocForActiveSpace(). If none exists for the active Space, skip injection (not an error — users opt in by writing one).
- Replace
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):
- Bootstrap singletons if missing (idempotent):
- Default agent for this Space's type (see §3).
- Default scene "Übersicht" with empty layout.
- 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. - No data reset — all reactive queries already pivot via
scopedForModule()on the newactiveSpaceId. Module stores don't need custom reset logic. - 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 nullon sync tables forglobalTags,tag_groups,workbench_scenes,ai_agents,ai_missions,kontext_docs. Dropuser_idfrom the first four (keep only on the newuser_tag_presetstable, which remains user-scoped).- Two-step Postgres backfill: add nullable
space_id, backfill via join (update … set space_id = personal_space_of(user_id)), thenset not null, then dropuser_id.
- Two-step Postgres backfill: add nullable
- 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_presetsin the user-scoped mapping.
Phase 7 — Cleanup + docs (0.5 day)
- Delete the
// TODO: auditencryption comment onglobalTags— resolved. - Update
apps/mana/apps/web/src/lib/data/DATA_LAYER_AUDIT.md: canonical rule is now "data records carryspaceId, notuserId; attribution lives in Actor columns". DocumentuserTagPresetsas the only user-scoped data table. - Add a section to
apps/mana/CLAUDE.md:Adding a new top-level table? It gets
spaceId, notuserId. User-level is reserved for identity, auth, profile, and explicit user-level templates (likeuserTagPresets). 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.mdwith commit refs after shipping.
- Regenerate
validate:alloutputs — 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
userIdandspaceIdcolumns (the canonical rule is one-or-the-other, based on the Space-scoped vs. user-level decision). validate:allgreen: 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 differenttag.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— the Spaces primitive this plan extends.workbench-cards-migration.md— the cards-vs-routes policy from the same strategic session.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— delete in Phase 8.