Supersedes per-space-vs-user-global-tags.md (which recommended defer under "ship fast" assumptions). Pre-live + unlimited resources changes the calculus: build the clean architecture now. Decision: tags, tag-groups, workbench scenes, AI agents, and AI missions all become Space-scoped. Only identity (user, session, profile, MK key) and per-device UI prefs stay user-level. Plan covers 8 phases across ~3–4 days: 1. Audit + schema design 2. Dexie migration (with backfill to user's Personal-Space) 3. Store APIs (implicit via scopedForModule wrapper) 4. Space-switch side-effects (reset active scene, bootstrap defaults) 5. Space-creation seeding (one-shot copy tags from Personal) 6. Backend (mana-sync + Postgres + RLS) 7. Docs + memory updates 8. Delete the old deferred plan Includes edge cases, success criteria, and reasoning for why β over γ (two clear levels beat one recursive primitive for user clarity). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
14 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 as fast as possible" assumptions; this plan assumes pre-live + unlimited resources.
Decision
Everything that the user creates — tags, tag-groups, workbench scenes, AI agents, AI missions — lives in a Space. Only identity and per-device preferences stay at the user level.
Two crisp levels:
| Level | What lives here | Examples |
|---|---|---|
| User | Identity, auth, session, profile, master-key, per-device UI preferences | user, session, profile, authKeys, userSettings.* |
| Space | Every data record the user creates | Tasks, events, notes, contacts, dreams, memoros, tags, tag-groups, scenes, agents, missions, workbench layouts — everything |
A "Space" is the tenancy boundary. Personal-Space is automatic and always exists for every user — most users spend most of their time there. Shared Spaces (Family, Team, Brand, Club, Practice) have explicit membership; their data is only visible to members via RLS.
Why β, not γ (recursive Context)
- β has two clear levels — Space (tenant) vs. View (filter 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.
Scope of this migration
In scope (become Space-scoped)
Currently user-global or ambiguously-scoped:
globalTags— central tag store. NospaceIdtoday.tagGroups— sibling ofglobalTags.workbenchScenes— scenes holdscopeTagIds+ layout; currently user-global in the shared scene store.aiAgents— default "Mana" agent is bootstrapped per user at first login.aiMissions— reference an agent + inputs; implicitly follow wherever agents live.- Any other top-level table that today carries
userIdbut notspaceId(verify during Phase 1 — see "Audit step" below).
Stays user-level
user,session,authKeys, MK key wrappingprofile(bio, avatar, locale, timezone) — identity-level- Per-device UI preferences (active scene id, theme toggle, last-used layout) — these are device-local anyway, stored in localStorage
- Access-tier claim (
tieron the JWT) — follows the user, gates whole apps
Already correctly Space-scoped (no change)
All entity tables that came through the Spaces-Foundation migration (~46 tables per the Spaces memory): tasks, events, notes, contacts, dreams, memoros, meditations, …
Junction tables (implicit)
All 19 tag junction tables (taskLabels, eventTags, contactTags,
…) stay plaintext FK-only. They inherit the parent record's spaceId
transitively — a taskLabels row pointing to task T is only visible
to members of T's Space. Nothing to change on the junction side.
Target data model
globalTags + tagGroups
interface LocalTag {
id: string;
spaceId: string; // NEW — required, indexed
name: string;
color: string;
icon?: string;
groupId?: string;
sortOrder: number;
// userId dropped — implied by space membership
// (createdBy audit column can stay if we want attribution)
createdAt: string;
updatedAt: string;
deletedAt?: string;
__fieldTimestamps?: Record<string, number>;
}
Dexie indexes: id, spaceId, [spaceId+name], [spaceId+sortOrder].
workbenchScenes
Already has layout + scopeTagIds?; add required spaceId. A scene
belongs to exactly one Space.
aiAgents + aiMissions
Same pattern — spaceId required.
Database hooks
apps/mana/apps/web/src/lib/data/database.ts:
- Creating hook today stamps
userIdfromcurrentUserstore. Change to stamp bothuserId(still useful for attribution / audit) andspaceIdfromgetActiveSpaceId(). Throw a clear error if no active Space is loaded — guards against races. - Sync engine (
sync.ts) already groups byappId; addspaceIdto the outgoing payload so the backend can RLS-enforce without trusting the client.
Phases
Phase 1 — Audit + schema design (0.5 day)
- Grep
apps/mana/apps/web/src/lib/data/database.tsfor every.store(...)definition; cross-check againstpackages/shared-typesto list every table that carriesuserIdbut notspaceId. Output a complete "tables to migrate" list — the five above are the obvious ones, but there may be others (audit_pendingChanges?_activity?kontextDoc?). - For each table, decide: Space-scoped (majority) or user-level (rare — only identity/device-pref). Document decisions in a table.
- Design Dexie v{next}: new columns + indexes + migration function.
- Design matching Postgres migration for
mana_sync: addspace_idcolumns, RLS policies that match the 46 already-migrated tables.
Deliverable: updated target schema section of this doc, ready to cut.
Phase 2 — Dexie migration (1 day)
- Bump Dexie version. Add migration function that:
- For every row in each target table, set
spaceId = user.personalSpaceId(we know it exists — signup hook guarantees). - Leave
userIdalone (kept for audit).
- For every row in each target table, set
- Register new tables with
scopedForModule()— e.g.scopedForModule('tags', 'globalTags'). This kicks in the active-Space filter at read time automatically. database.tscreating hook: stampspaceIdalongsideuserId.- Unit tests (fake-indexeddb): round-trip a tag through encrypt/write/read in two different Spaces, verify isolation.
Phase 3 — Store APIs (1 day)
packages/shared-stores/src/tags-local.svelte.ts:useAllTags()— already aliveQuery; after Phase 2'sscopedForModule(), it's implicitly filtered to the active Space. No API change.tagMutations.createTag()— no longer needs (or accepts) aspaceIdargument; the hook stamps it. If someone explicitly passes one, throw (would mean cross-Space write).getTagById(id)— still works; the caller is already in a Space context.
workbenchScenesStore—setActiveScene()stays same; deleting a scene deletes its layout + scopeTagIds together.- AI agents store —
bootstrapDefaultAgentruns per-Space, not per-user. Key offspaceId + kind: 'default'for idempotency. - Missions store — unchanged semantically, just inherits
spaceIdfrom hook.
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, trigger
side-effects:
- Reset active scene: look up the new Space's default scene and
call
workbenchScenesStore.setActiveScene(defaultSceneId). If no default exists yet (new Space, no scenes), create one via the Space-bootstrap flow below. - Bootstrap missing singletons: if the new Space has no default
agent, create a "Mana" agent. If no scenes, create a default
"Übersicht" scene. These bootstraps run idempotently (key on
[spaceId, kind]). - Keep per-device state: theme, last-used module, panel sizes don't reset — they're UI prefs, not data.
Implemented as a new onActiveSpaceChanged() hook in
active-space.svelte.ts, invoked at the end of loadActiveSpace().
Phase 5 — Space-creation seeding (0.5 day)
apps/mana/apps/web/src/lib/components/layout/SpaceCreateDialog.svelte:
Add an optional "Tags aus Personal kopieren" checkbox (default: on for new Solo-Spaces, off for Shared-Spaces).
Mechanism: after the new Space is created and activated, if checked, one-shot copy:
async function copyTagsFromPersonal(personalSpaceId: string, newSpaceId: string) {
const personalTags = await db.table('globalTags')
.where('spaceId').equals(personalSpaceId).toArray();
const groupIdMap: Record<string, string> = {};
const personalGroups = await db.table('tagGroups')
.where('spaceId').equals(personalSpaceId).toArray();
for (const g of personalGroups) {
const newId = crypto.randomUUID();
groupIdMap[g.id] = newId;
await db.table('tagGroups').add({ ...g, id: newId, spaceId: newSpaceId });
}
for (const t of personalTags) {
await db.table('globalTags').add({
...t, id: crypto.randomUUID(), spaceId: newSpaceId,
groupId: t.groupId ? groupIdMap[t.groupId] : undefined,
});
}
}
One-shot copy, not a live link. Users can then diverge the tag set per Space without cross-contamination.
Phase 6 — Backend (mana-sync + RLS) (1 day)
- PostgreSQL migration: add
space_id text not nullon the corresponding sync tables. Default fill: the user's Personal-Space id (we can't know this server-side without a lookup; might need a two-step migration — add nullable, backfill via join, thennot null). - RLS policy: a row is visible iff the requesting session is a
member of
space_id. Matches the existing policy already used for the 46 migrated tables. - mana-sync config: register the new tables in the collection→space-table mapping.
Phase 7 — Cleanup + docs (0.5 day)
- Delete dead comments in
tags-local.svelte.tsabout user-global semantics. - Update
apps/mana/apps/web/src/lib/data/DATA_LAYER_AUDIT.md: new canonical rule — "every data table carriesspaceId". - Add a short section to
apps/mana/CLAUDE.md: "Adding a new top-level table? It getsspaceId. User-level is reserved for identity + device-prefs." - Update memory files:
feedback_cards_over_subroutes.md— note that tags, scenes, agents are all Space-scoped now- Add a new
project_space_scoped_datamodel.mdentry with commit refs after shipping
Phase 8 — Delete the old decision doc (5 min)
per-space-vs-user-global-tags.md gets superseded. Either delete it
or leave it with a single-line banner pointing here. Prefer delete
— stale plans confuse future readers.
Edge cases & decisions
What if a user is member of another user's Shared-Space when migration runs?
They are — the Spaces-Foundation already supports multi-member. Their
existing user-global tags should stay in their own Personal-Space.
Don't migrate them into shared spaces. The Phase 2 migration keys off
"this row has userId = X, so spaceId = personalSpaceOf(X)".
What if the signup hook failed and a user has no Personal-Space?
loadActiveSpace() already handles this as a fatal error state
("No accessible space found — signup hook may not have run"). Phase 2
migration refuses to run until that's repaired.
Should the user's Personal-Space be specially marked as "the real default"?
Yes — it already is (SpaceType = 'personal', auto-created on
signup, one per user, can't be deleted). No schema change needed.
What happens to a user's default agent "Mana"?
In Phase 3, the bootstrap hook runs per-Space. Existing Mana agent
in each user's Personal-Space migrates automatically (it gets
spaceId = personalSpaceId in Phase 2). Shared Spaces get their own
fresh Mana agent on first use by any member.
Policy memory is per-agent-per-Space. A user's agents across multiple Spaces are independent — that's a feature, not a bug (agent in brand-Space doesn't know user's personal notes).
Tag-preset at user level — do we want this?
Deferred. The one-shot "copy from Personal" in Phase 5 covers the common case. A dedicated "Tag Preset" record at user level can be added later if users ask for it.
What about the // TODO: audit on globalTags encryption?
Worth a separate sweep. With Space-scoped tags + RLS, the case for encrypting tag names weakens (server already filters per-member). But the current plaintext sync is still a leak surface (e.g. if the Postgres backup leaks, tag names are visible). Park this as a follow-up: "audit encryption of now-Space-scoped metadata tables (tags, tagGroups, scene titles, agent names)".
Success criteria
Before merging:
- Every data table in
database.tseither carriesspaceIdor is on the explicit "user-level, by design" list, documented inDATA_LAYER_AUDIT.md. - Switching active Space in the UI flips the visible tag list, scene list, agent list, mission list — all at once, no stale state.
- Creating a new Shared-Space (type
teamorfamily) starts with an empty tag list. Creating a new Solo-Space (typebrandorclubwhere the user is the only member) offers the "copy from Personal" toggle. pnpm validate:allis green (turbo invariants + pgSchema + crypto registry + theme tokens).- Smoketest: in Personal-Space, create tag "Urgent". Switch to a
new Shared-Space.
useAllTags()returns an empty list. Switch back. "Urgent" is visible again.
Timeline
~3–4 focused days across all phases. Biggest risk is Phase 6 (backend migration) because it touches production-shaped schemas — even though we're pre-live, getting the Postgres migration right the first time saves a re-migration later.
Related
spaces-foundation.md— the primitive this plan extends.workbench-cards-migration.md— the workbench-cards policy derived from the same 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— kept for historical context; delete in Phase 8.