mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
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>
This commit is contained in:
parent
ea71d3c215
commit
129971ffc3
1 changed files with 332 additions and 227 deletions
|
|
@ -1,342 +1,447 @@
|
||||||
# Space-scoped data model (Modell β)
|
# Space-scoped data model (Modell β)
|
||||||
|
|
||||||
_Started 2026-04-22._
|
_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
|
## Decision
|
||||||
|
|
||||||
Everything that the user creates — tags, tag-groups, workbench scenes,
|
Everything that the user creates — tags, tag-groups, workbench scenes,
|
||||||
AI agents, AI missions — **lives in a Space.** Only identity and
|
AI agents, AI missions, kontextDoc — **lives in a Space.** Only
|
||||||
per-device preferences stay at the user level.
|
identity, authentication, session, profile, master-key material, and
|
||||||
|
per-device UI preferences stay at the user level.
|
||||||
|
|
||||||
Two crisp levels:
|
Two crisp levels:
|
||||||
|
|
||||||
| Level | What lives here | Examples |
|
| Level | What lives here | Examples |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| **User** | Identity, auth, session, profile, master-key, per-device UI preferences | `user`, `session`, `profile`, `authKeys`, `userSettings.*` |
|
| **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 | Tasks, events, notes, contacts, dreams, memoros, tags, tag-groups, scenes, agents, missions, workbench layouts — everything |
|
| **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
|
"Device" isn't a data model — it's localStorage conventions. Listed
|
||||||
always exists for every user — most users spend most of their time
|
for completeness so nothing falls through the cracks.
|
||||||
there. Shared Spaces (Family, Team, Brand, Club, Practice) have
|
|
||||||
explicit membership; their data is only visible to members via RLS.
|
|
||||||
|
|
||||||
## Why β, not γ (recursive Context)
|
### Why β, not γ (recursive Context)
|
||||||
|
|
||||||
- β has **two clear levels** — Space (tenant) vs. View (filter inside
|
- β has **two clear data levels** — Space (tenant) vs. Scene-as-filter
|
||||||
that Space). Users always know where they are.
|
(view inside that Space). Users always know where they are.
|
||||||
- γ (one recursive `Context` primitive with arbitrary nesting) is more
|
- γ (one recursive `Context` primitive with arbitrary nesting) is more
|
||||||
flexible but mentally heavier — "am I in a Space or a sub-Space?" is
|
flexible but mentally heavier — "am I in a Space or a sub-Space?" is
|
||||||
a real question users would have to answer.
|
a real question users would have to answer.
|
||||||
- β is a natural stepping stone if we ever want γ: a γ-Context is just
|
- β is a natural stepping stone if we ever want γ: a γ-Context is just
|
||||||
a β-Space with children.
|
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)
|
### 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.
|
### Stays user-level (identity / preferences)
|
||||||
- `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
|
- `user`, `session`, `authKeys`, MK key wrapping
|
||||||
- `profile` (bio, avatar, locale, timezone) — identity-level
|
- `profile` (bio, avatar, locale, timezone)
|
||||||
- Per-device UI preferences (active scene id, theme toggle, last-used
|
- `userSettings` — user-wide prefs (theme default, locale)
|
||||||
layout) — these are device-local anyway, stored in localStorage
|
- `userTagPresets` — **new table** (see §5)
|
||||||
- Access-tier claim (`tier` on the JWT) — follows the user, gates
|
- Access-tier claim on the JWT
|
||||||
whole apps
|
|
||||||
|
|
||||||
### Already correctly Space-scoped (no change)
|
### Ephemeral, per-device (localStorage only)
|
||||||
|
|
||||||
All entity tables that came through the Spaces-Foundation migration
|
- Active scene per Space:
|
||||||
(~46 tables per the Spaces memory): tasks, events, notes, contacts,
|
`mana:workbench:activeSceneId:${spaceId}`
|
||||||
dreams, memoros, meditations, …
|
- 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`,
|
All 19 tag junction tables (`taskLabels`, `eventTags`, `contactTags`,
|
||||||
…) stay plaintext FK-only. They inherit the parent record's `spaceId`
|
…) are FK-only and inherit `spaceId` from their parent record. Phase 1
|
||||||
transitively — a `taskLabels` row pointing to task T is only visible
|
confirms that **every** junction row's parent actually carries
|
||||||
to members of T's Space. Nothing to change on the junction side.
|
`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
|
```ts
|
||||||
interface LocalTag {
|
interface LocalTag {
|
||||||
id: string;
|
id: string;
|
||||||
spaceId: string; // NEW — required, indexed
|
spaceId: string; // NEW — required, indexed
|
||||||
name: string;
|
name: string; // encrypted (see Phase 2)
|
||||||
color: string;
|
color: string;
|
||||||
icon?: string;
|
icon?: string; // encrypted
|
||||||
groupId?: string;
|
groupId?: string;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
// userId dropped — implied by space membership
|
|
||||||
// (createdBy audit column can stay if we want attribution)
|
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
deletedAt?: string;
|
deletedAt?: string;
|
||||||
__fieldTimestamps?: Record<string, number>;
|
__fieldTimestamps?: Record<string, number>;
|
||||||
|
__lastActor?: Actor; // from AI-Workbench rollout
|
||||||
|
__fieldActors?: Record<string, Actor>;
|
||||||
|
// NO userId
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Dexie indexes: `id, spaceId, [spaceId+name], [spaceId+sortOrder]`.
|
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`
|
### `workbenchScenes`
|
||||||
|
|
||||||
Already has layout + `scopeTagIds?`; add required `spaceId`. A scene
|
Already has `title`, layout, `scopeTagIds?`. Add `spaceId`. Encrypt
|
||||||
belongs to exactly one Space.
|
`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.
|
```ts
|
||||||
Change to stamp both `userId` (still useful for attribution /
|
interface LocalKontextDoc {
|
||||||
audit) **and** `spaceId` from `getActiveSpaceId()`. Throw a clear
|
id: string; // uuid, one per Space
|
||||||
error if no active Space is loaded — guards against races.
|
spaceId: string; // required, unique in-space
|
||||||
- **Sync engine** (`sync.ts`) already groups by `appId`; add `spaceId`
|
type: 'kontextDoc'; // discriminator, for the [spaceId+type] unique index
|
||||||
to the outgoing payload so the backend can RLS-enforce without
|
content: string; // encrypted (already is)
|
||||||
trusting the client.
|
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)
|
||||||
|
|
||||||
|
```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
|
## 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
|
- Enumerate every Dexie `store(...)` definition in
|
||||||
`.store(...)` definition; cross-check against
|
`apps/mana/apps/web/src/lib/data/database.ts`. Cross-check against
|
||||||
`packages/shared-types` to list every table that carries `userId`
|
`packages/shared-types`.
|
||||||
but not `spaceId`. Output a complete "tables to migrate" list —
|
- For every table, decide:
|
||||||
the five above are the obvious ones, but there may be others
|
- **Space-scoped** (majority — migrate)
|
||||||
(audit `_pendingChanges`? `_activity`? `kontextDoc`?).
|
- **User-level** (only identity / profile / preferences /
|
||||||
- For each table, decide: Space-scoped (majority) or user-level
|
`userTagPresets`)
|
||||||
(rare — only identity/device-pref). Document decisions in a table.
|
- **Junction** (inherits from parent — verify parent has `spaceId`)
|
||||||
- Design Dexie v{next}: new columns + indexes + migration function.
|
- Verify every junction row's parent is Space-scoped. Any junction
|
||||||
- Design matching Postgres migration for `mana_sync`: add `space_id`
|
still pointing at a user-global table is a bug to flag.
|
||||||
columns, RLS policies that match the 46 already-migrated tables.
|
- 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
|
### Phase 2 — Dexie migration (1.5 days)
|
||||||
cut.
|
|
||||||
|
|
||||||
### Phase 2 — Dexie migration (1 day)
|
- Bump Dexie version. Write migration function that for each target
|
||||||
|
table:
|
||||||
- Bump Dexie version. Add migration function that:
|
1. Adds `spaceId = user.personalSpaceId` (signup hook guarantees
|
||||||
1. For every row in each target table, set `spaceId =
|
it exists).
|
||||||
user.personalSpaceId` (we know it exists — signup hook
|
2. **Drops `userId`** — attribution is the Actor system's job.
|
||||||
guarantees).
|
(`__lastActor` is already populated for records created after
|
||||||
2. Leave `userId` alone (kept for audit).
|
the AI-Workbench rollout; pre-rollout rows get `__lastActor =
|
||||||
- Register new tables with `scopedForModule()` — e.g.
|
{ kind: 'user', principalId: userId, displayName: user.name }`
|
||||||
`scopedForModule('tags', 'globalTags')`. This kicks in the
|
during the migration — a one-time backfill, then `userId` is
|
||||||
active-Space filter at read time automatically.
|
deleted).
|
||||||
- `database.ts` creating hook: stamp `spaceId` alongside `userId`.
|
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
|
- Unit tests (fake-indexeddb): round-trip a tag through
|
||||||
encrypt/write/read in two different Spaces, verify isolation.
|
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`:
|
- `packages/shared-stores/src/tags-local.svelte.ts`:
|
||||||
- `useAllTags()` — already a `liveQuery`; after Phase 2's
|
- `useAllTags()` — unchanged surface; implicit Space filter from
|
||||||
`scopedForModule()`, it's implicitly filtered to the active
|
`scopedForModule()`. Returned tags have `name` already decrypted
|
||||||
Space. No API change.
|
(crypto pipeline).
|
||||||
- `tagMutations.createTag()` — no longer needs (or accepts) a
|
- `tagMutations.createTag(input)` — no `spaceId` argument; hook
|
||||||
`spaceId` argument; the hook stamps it. If someone *explicitly*
|
stamps it. Refuses explicit cross-Space writes.
|
||||||
passes one, throw (would mean cross-Space write).
|
- `userTagPresets` store: `useTagPresets()`, `createPreset()`,
|
||||||
- `getTagById(id)` — still works; the caller is already in a Space
|
`applyPresetToSpace(presetId, spaceId)` — the apply flow
|
||||||
context.
|
one-shot-copies preset entries into the target Space as fresh
|
||||||
- `workbenchScenesStore` — `setActiveScene()` stays same; deleting
|
`globalTags` rows.
|
||||||
a scene deletes its layout + scopeTagIds together.
|
- `workbenchScenesStore`:
|
||||||
- AI agents store — `bootstrapDefaultAgent` runs per-Space, not
|
- `useScenes()` → Space-filtered live query.
|
||||||
per-user. Key off `spaceId + kind: 'default'` for idempotency.
|
- Per-Space default scene bootstrap (see Phase 4).
|
||||||
- Missions store — unchanged semantically, just inherits `spaceId`
|
- AI agents store:
|
||||||
from hook.
|
- `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)
|
### Phase 4 — Space-switch behavior (0.5 day)
|
||||||
|
|
||||||
`apps/mana/apps/web/src/lib/data/scope/active-space.svelte.ts`:
|
`apps/mana/apps/web/src/lib/data/scope/active-space.svelte.ts`:
|
||||||
|
|
||||||
After `loadActiveSpace()` completes and sets `active = space`, trigger
|
After `loadActiveSpace()` completes and sets `active = space`, fire
|
||||||
side-effects:
|
`onActiveSpaceChanged(space)`:
|
||||||
|
|
||||||
1. **Reset active scene:** look up the new Space's default scene and
|
1. **Bootstrap singletons if missing** (idempotent):
|
||||||
call `workbenchScenesStore.setActiveScene(defaultSceneId)`. If no
|
- Default agent for this Space's type (see §3).
|
||||||
default exists yet (new Space, no scenes), create one via the
|
- Default scene "Übersicht" with empty layout.
|
||||||
Space-bootstrap flow below.
|
2. **Restore per-device active scene** for this Space from
|
||||||
2. **Bootstrap missing singletons:** if the new Space has no default
|
`localStorage.getItem('mana:workbench:activeSceneId:' +
|
||||||
agent, create a "Mana" agent. If no scenes, create a default
|
space.id)`. If null or stale, fall back to the Space's default
|
||||||
"Übersicht" scene. These bootstraps run idempotently (key on
|
scene.
|
||||||
`[spaceId, kind]`).
|
3. **No data reset** — all reactive queries already pivot via
|
||||||
3. **Keep per-device state:** theme, last-used module, panel sizes
|
`scopedForModule()` on the new `activeSpaceId`. Module stores
|
||||||
don't reset — they're UI prefs, not data.
|
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
|
`workbenchScenesStore.setActiveScene(sceneId)` writes the per-Space
|
||||||
`active-space.svelte.ts`, invoked at the end of `loadActiveSpace()`.
|
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`:
|
`apps/mana/apps/web/src/lib/components/layout/SpaceCreateDialog.svelte`:
|
||||||
|
|
||||||
Add an optional "Tags aus Personal kopieren" checkbox (default: on
|
Add a "Tag-Set" section to the create dialog:
|
||||||
for new Solo-Spaces, off for Shared-Spaces).
|
|
||||||
|
|
||||||
Mechanism: after the new Space is created and activated, if checked,
|
- Dropdown of the user's `userTagPresets` (if any), plus built-in
|
||||||
one-shot copy:
|
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
|
Admin-style entry in the user's Settings → "Tag-Presets" to
|
||||||
async function copyTagsFromPersonal(personalSpaceId: string, newSpaceId: string) {
|
create/edit presets (CRUD for `userTagPresets`). Any existing Space's
|
||||||
const personalTags = await db.table('globalTags')
|
tag set can be "exported as preset" from its TagManager UI.
|
||||||
.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
|
### Phase 6 — Backend (mana-sync + Postgres + RLS) (1 day)
|
||||||
set per Space without cross-contamination.
|
|
||||||
|
|
||||||
### Phase 6 — Backend (mana-sync + RLS) (1 day)
|
- PostgreSQL migration: add `space_id text not null` on sync tables
|
||||||
|
for `globalTags`, `tag_groups`, `workbench_scenes`, `ai_agents`,
|
||||||
- PostgreSQL migration: add `space_id text not null` on the
|
`ai_missions`, `kontext_docs`. Drop `user_id` from the first
|
||||||
corresponding sync tables. Default fill: the user's Personal-Space
|
four (keep only on the new `user_tag_presets` table, which
|
||||||
id (we can't know this server-side without a lookup; might need a
|
remains user-scoped).
|
||||||
two-step migration — add nullable, backfill via join, then `not
|
- Two-step Postgres backfill: add nullable `space_id`, backfill
|
||||||
null`).
|
via join (`update … set space_id = personal_space_of(user_id)`),
|
||||||
- RLS policy: a row is visible iff the requesting session is a
|
then `set not null`, then drop `user_id`.
|
||||||
member of `space_id`. Matches the existing policy already used for
|
- RLS policies: membership-based for every Space-scoped table
|
||||||
the 46 migrated tables.
|
(matches existing 46-table pattern).
|
||||||
- mana-sync config: register the new tables in the
|
- `user_tag_presets`: RLS = `user_id = current_user_id()`.
|
||||||
collection→space-table mapping.
|
- 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)
|
### Phase 7 — Cleanup + docs (0.5 day)
|
||||||
|
|
||||||
- Delete dead comments in `tags-local.svelte.ts` about user-global
|
- Delete the `// TODO: audit` encryption comment on `globalTags` —
|
||||||
semantics.
|
resolved.
|
||||||
- Update `apps/mana/apps/web/src/lib/data/DATA_LAYER_AUDIT.md`:
|
- Update `apps/mana/apps/web/src/lib/data/DATA_LAYER_AUDIT.md`:
|
||||||
new canonical rule — "every data table carries `spaceId`".
|
canonical rule is now "data records carry `spaceId`, not `userId`;
|
||||||
- Add a short section to `apps/mana/CLAUDE.md`: "Adding a new
|
attribution lives in Actor columns". Document
|
||||||
top-level table? It gets `spaceId`. User-level is reserved for
|
`userTagPresets` as the only user-scoped data table.
|
||||||
identity + device-prefs."
|
- 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:
|
- Update memory files:
|
||||||
- `feedback_cards_over_subroutes.md` — note that tags, scenes,
|
- `feedback_cards_over_subroutes.md` — note that tags, scenes,
|
||||||
agents are all Space-scoped now
|
agents, missions, kontextDoc are all Space-scoped.
|
||||||
- Add a new `project_space_scoped_datamodel.md` entry with
|
- Add `project_space_scoped_datamodel.md` with commit refs after
|
||||||
commit refs after shipping
|
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
|
`per-space-vs-user-global-tags.md` gets deleted in the same PR as
|
||||||
or leave it with a single-line banner pointing here. **Prefer delete**
|
Phase 7. Git history preserves the deferred-decision reasoning for
|
||||||
— stale plans confuse future readers.
|
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
|
**Signup hook failed — no Personal-Space?** Phase 2 migration refuses
|
||||||
existing user-global tags should stay in their own Personal-Space.
|
to run. Repair that user's state first, then retry.
|
||||||
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?
|
**`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
|
**Default agent name collision across user's Spaces?** Names are
|
||||||
("No accessible space found — signup hook may not have run"). Phase 2
|
SpaceType-aware, so three Spaces give three different defaults. Users
|
||||||
migration refuses to run until that's repaired.
|
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
|
**Cross-device preset drift?** `userTagPresets` syncs via mana-sync
|
||||||
signup, one per user, can't be deleted). No schema change needed.
|
with user-level RLS. Same model as `profile`.
|
||||||
|
|
||||||
### 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
|
## Success criteria
|
||||||
|
|
||||||
Before merging:
|
Before merging:
|
||||||
|
|
||||||
- [ ] Every data table in `database.ts` either carries `spaceId` or
|
- [ ] No table in the Dexie DB has both `userId` and `spaceId` columns
|
||||||
is on the explicit "user-level, by design" list, documented in
|
(the canonical rule is one-or-the-other, based on the
|
||||||
`DATA_LAYER_AUDIT.md`.
|
Space-scoped vs. user-level decision).
|
||||||
- [ ] Switching active Space in the UI flips the visible tag list,
|
- [ ] `validate:all` green: turbo recursion · pgSchema · crypto
|
||||||
scene list, agent list, mission list — all at once, no stale
|
registry · theme tokens · CSS utilities.
|
||||||
state.
|
- [ ] Switching active Space in the UI flips: tag list · scene list ·
|
||||||
- [ ] Creating a new Shared-Space (type `team` or `family`) starts
|
agent list · mission list · kontextDoc — all at once, no stale
|
||||||
with an empty tag list. Creating a new Solo-Space (type `brand`
|
state in any module.
|
||||||
or `club` where the user is the only member) offers the
|
- [ ] Creating a new Shared-Space starts with an empty tag list.
|
||||||
"copy from Personal" toggle.
|
Creating a new Solo-Space offers the preset picker with
|
||||||
- [ ] `pnpm validate:all` is green (turbo invariants + pgSchema +
|
user-defined options + the "Aus Personal kopieren" shortcut.
|
||||||
crypto registry + theme tokens).
|
- [ ] Active scene persists per-Space-per-device: switch Space A →
|
||||||
- [ ] Smoketest: in Personal-Space, create tag "Urgent". Switch to a
|
scene X, switch to Space B → scene Y, switch back to A → scene
|
||||||
new Shared-Space. `useAllTags()` returns an empty list. Switch
|
X restored.
|
||||||
back. "Urgent" is visible again.
|
- [ ] 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
|
## Timeline
|
||||||
|
|
||||||
~3–4 focused days across all phases. Biggest risk is Phase 6
|
~4–5 focused days across all phases (up from 3–4 in the previous
|
||||||
(backend migration) because it touches production-shaped schemas —
|
draft; the extra day covers encryption wiring, `userTagPresets` CRUD,
|
||||||
even though we're pre-live, getting the Postgres migration right the
|
and the `userId` → Actor attribution cleanup).
|
||||||
first time saves a re-migration later.
|
|
||||||
|
|
||||||
## Related
|
## Related
|
||||||
|
|
||||||
- [`spaces-foundation.md`](./spaces-foundation.md) — the primitive
|
- [`spaces-foundation.md`](./spaces-foundation.md) — the Spaces
|
||||||
this plan extends.
|
primitive this plan extends.
|
||||||
- [`workbench-cards-migration.md`](./workbench-cards-migration.md) —
|
- [`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
|
- [`scene-scope-empty-state.md`](./scene-scope-empty-state.md) — UX
|
||||||
for the scope-filter empty state; integrates naturally once scenes
|
for the scope-filter empty state; integrates naturally once scenes
|
||||||
are per-Space.
|
are per-Space.
|
||||||
- **Deprecated** by this doc:
|
- **Deprecated** by this doc:
|
||||||
[`per-space-vs-user-global-tags.md`](./per-space-vs-user-global-tags.md)
|
[`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.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue