diff --git a/docs/plans/space-scoped-data-model.md b/docs/plans/space-scoped-data-model.md index bb5a19da0..012793037 100644 --- a/docs/plans/space-scoped-data-model.md +++ b/docs/plans/space-scoped-data-model.md @@ -1,342 +1,447 @@ # 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._ +_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 — **lives in a Space.** Only identity and -per-device preferences stay at the user level. +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, 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 | +| **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 | -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. +"Device" isn't a data model — it's localStorage conventions. Listed +for completeness so nothing falls through the cracks. -## Why β, not γ (recursive Context) +### Why β, not γ (recursive Context) -- β has **two clear levels** — Space (tenant) vs. View (filter inside - that Space). Users always know where they are. +- β 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. -## Scope of this migration +## 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) -Currently user-global or ambiguously-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. -- `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 +### Stays user-level (identity / preferences) - `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 +- `profile` (bio, avatar, locale, timezone) +- `userSettings` — user-wide prefs (theme default, locale) +- `userTagPresets` — **new table** (see §5) +- Access-tier claim on the JWT -### Already correctly Space-scoped (no change) +### Ephemeral, per-device (localStorage only) -All entity tables that came through the Spaces-Foundation migration -(~46 tables per the Spaces memory): tasks, events, notes, contacts, -dreams, memoros, meditations, … +- 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 -### Junction tables (implicit) +### 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`, -…) 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. +…) 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 data model +## Target schemas -### `globalTags` + `tagGroups` +### `globalTags` ```ts interface LocalTag { id: string; - spaceId: string; // NEW — required, indexed - name: string; + spaceId: string; // NEW — required, indexed + name: string; // encrypted (see Phase 2) color: string; - icon?: string; + icon?: string; // encrypted 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; + __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 layout + `scopeTagIds?`; add required `spaceId`. A scene -belongs to exactly one Space. +Already has `title`, layout, `scopeTagIds?`. Add `spaceId`. Encrypt +`title` (it's user-authored). -### `aiAgents` + `aiMissions` +### `aiAgents`, `aiMissions` -Same pattern — `spaceId` required. +`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. -### Database hooks +### `kontextDoc` -`apps/mana/apps/web/src/lib/data/database.ts`: +Today a user-level singleton. New shape: -- **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. +```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 design (0.5 day) +### Phase 1 — Audit + schema finalisation (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. +- 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. -Deliverable: updated target schema section of this doc, ready to -cut. +### Phase 2 — Dexie migration (1.5 days) -### 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`. +- 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 (1 day) +### Phase 3 — Store APIs + runner integration (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. + - `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`, trigger -side-effects: +After `loadActiveSpace()` completes and sets `active = space`, fire +`onActiveSpaceChanged(space)`: -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. +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. -Implemented as a new `onActiveSpaceChanged()` hook in -`active-space.svelte.ts`, invoked at the end of `loadActiveSpace()`. +`workbenchScenesStore.setActiveScene(sceneId)` writes the per-Space +localStorage key, not the old single-key one. -### Phase 5 — Space-creation seeding (0.5 day) +### Phase 5 — Space creation + preset application (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). +Add a "Tag-Set" section to the create dialog: -Mechanism: after the new Space is created and activated, if checked, -one-shot copy: +- 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. -```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, - }); - } -} -``` +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. -One-shot **copy**, not a live link. Users can then diverge the tag -set per Space without cross-contamination. +### Phase 6 — Backend (mana-sync + Postgres + RLS) (1 day) -### 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. +- 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 dead comments in `tags-local.svelte.ts` about user-global - semantics. +- Delete the `// TODO: audit` encryption comment on `globalTags` — + resolved. - 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." + 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 are all Space-scoped now - - Add a new `project_space_scoped_datamodel.md` entry with - commit refs after shipping + 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 old decision doc (5 min) +### Phase 8 — Delete the deprecated plan (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. +`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 +## Edge cases & decisions (baked in) -### What if a user is member of *another* user's Shared-Space when migration runs? +**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. -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)`". +**Signup hook failed — no Personal-Space?** Phase 2 migration refuses +to run. Repair that user's state first, then retry. -### What if the signup hook failed and a user has no Personal-Space? +**`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. -`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. +**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. -### Should the user's Personal-Space be specially marked as "the real default"? +**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. -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)". +**Cross-device preset drift?** `userTagPresets` syncs via mana-sync +with user-level RLS. Same model as `profile`. ## 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. +- [ ] 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 -~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. +~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 primitive - this plan extends. +- [`spaces-foundation.md`](./spaces-foundation.md) — the Spaces + primitive this plan extends. - [`workbench-cards-migration.md`](./workbench-cards-migration.md) — - the workbench-cards policy derived from the same session. + 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) - — kept for historical context; delete in Phase 8. + — delete in Phase 8.