managarten/docs/plans/space-scoped-data-model.md
Till JS 470f3b1b6c docs(plans): space-scoped data model (Modell β) — commit plan
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>
2026-04-22 15:54:31 +02:00

342 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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<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 `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<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 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
~34 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.