managarten/docs/plans/space-scoped-data-model.md
Till JS 129971ffc3 docs(plans): revise space-scoped plan — remove legacy residues
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>
2026-04-22 16:23:42 +02:00

18 KiB
Raw Blame History

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 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.

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)

  • 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.

Stays user-level (identity / preferences)

  • user, session, authKeys, MK key wrapping
  • profile (bio, avatar, locale, timezone)
  • userSettings — user-wide prefs (theme default, locale)
  • userTagPresetsnew 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 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.

Phase 2 — Dexie migration (1.5 days)

  • 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 + runner integration (1 day)

  • packages/shared-stores/src/tags-local.svelte.ts:
    • 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, fire onActiveSpaceChanged(space):

  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.

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 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 the // TODO: audit encryption comment on globalTags — resolved.
  • Update apps/mana/apps/web/src/lib/data/DATA_LAYER_AUDIT.md: 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, 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 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 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

~45 focused days across all phases (up from 34 in the previous draft; the extra day covers encryption wiring, userTagPresets CRUD, and the userId → Actor attribution cleanup).