From 470f3b1b6c87f64740f8d9e90f1b5cd0a98c3fb6 Mon Sep 17 00:00:00 2001 From: Till JS Date: Wed, 22 Apr 2026 15:54:31 +0200 Subject: [PATCH] =?UTF-8?q?docs(plans):=20space-scoped=20data=20model=20(M?= =?UTF-8?q?odell=20=CE=B2)=20=E2=80=94=20commit=20plan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/plans/space-scoped-data-model.md | 342 ++++++++++++++++++++++++++ 1 file changed, 342 insertions(+) create mode 100644 docs/plans/space-scoped-data-model.md diff --git a/docs/plans/space-scoped-data-model.md b/docs/plans/space-scoped-data-model.md new file mode 100644 index 000000000..bb5a19da0 --- /dev/null +++ b/docs/plans/space-scoped-data-model.md @@ -0,0 +1,342 @@ +# 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 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 `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. + +## Scope of this migration + +### In scope (become Space-scoped) + +Currently user-global or ambiguously-scoped: + +- `globalTags` — central tag store. No `spaceId` today. +- `tagGroups` — sibling of `globalTags`. +- `workbenchScenes` — scenes hold `scopeTagIds` + 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 `userId` but not + `spaceId` (verify during Phase 1 — see "Audit step" below). + +### Stays user-level + +- `user`, `session`, `authKeys`, MK key wrapping +- `profile` (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 (`tier` on 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` + +```ts +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; +} +``` + +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 `userId` from `currentUser` store. + Change to stamp both `userId` (still useful for attribution / + audit) **and** `spaceId` from `getActiveSpaceId()`. Throw a clear + error if no active Space is loaded — guards against races. +- **Sync engine** (`sync.ts`) already groups by `appId`; add `spaceId` + to 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.ts` for every + `.store(...)` definition; cross-check against + `packages/shared-types` to list every table that carries `userId` + but not `spaceId`. 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`: add `space_id` + columns, 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: + 1. For every row in each target table, set `spaceId = + user.personalSpaceId` (we know it exists — signup hook + guarantees). + 2. Leave `userId` alone (kept for audit). +- Register new tables with `scopedForModule()` — e.g. + `scopedForModule('tags', 'globalTags')`. This kicks in the + active-Space filter at read time automatically. +- `database.ts` creating hook: stamp `spaceId` alongside `userId`. +- 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 a `liveQuery`; after Phase 2's + `scopedForModule()`, it's implicitly filtered to the active + Space. No API change. + - `tagMutations.createTag()` — no longer needs (or accepts) a + `spaceId` argument; 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 — `bootstrapDefaultAgent` runs per-Space, not + per-user. Key off `spaceId + kind: 'default'` for idempotency. +- Missions store — unchanged semantically, just inherits `spaceId` + from 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: + +1. **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. +2. **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]`). +3. **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: + +```ts +async function copyTagsFromPersonal(personalSpaceId: string, newSpaceId: string) { + const personalTags = await db.table('globalTags') + .where('spaceId').equals(personalSpaceId).toArray(); + const groupIdMap: Record = {}; + 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 null` on 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, then `not + 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.ts` about user-global + semantics. +- Update `apps/mana/apps/web/src/lib/data/DATA_LAYER_AUDIT.md`: + new canonical rule — "every data table carries `spaceId`". +- Add a short section to `apps/mana/CLAUDE.md`: "Adding a new + top-level table? It gets `spaceId`. 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.md` entry 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.ts` either carries `spaceId` or + is on the explicit "user-level, by design" list, documented in + `DATA_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 `team` or `family`) starts + with an empty tag list. Creating a new Solo-Space (type `brand` + or `club` where the user is the only member) offers the + "copy from Personal" toggle. +- [ ] `pnpm validate:all` is 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`](./spaces-foundation.md) — the primitive + this plan extends. +- [`workbench-cards-migration.md`](./workbench-cards-migration.md) — + the workbench-cards policy derived from the same 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) + — kept for historical context; delete in Phase 8.