Skip to content

docs(multi-profile): RFC and design for multi-profile data isolation#16716

Draft
defi-failure wants to merge 2 commits into
mainfrom
docs/multi-profile
Draft

docs(multi-profile): RFC and design for multi-profile data isolation#16716
defi-failure wants to merge 2 commits into
mainfrom
docs/multi-profile

Conversation

@defi-failure

Copy link
Copy Markdown
Collaborator

What this PR does

Before this PR:

  • No design documentation exists for multi-profile support; the app assumes a single fixed data root.

After this PR:

  • Adds the design documentation for multi-profile data isolation under v2-refactor-temp/docs/multi-profile/:
    • rfc-multi-profile.md — intent and decisions: motivation, database-level isolation, the ProfileActivatable activation model, switch semantics, threat/concurrency model, observability contract, and non-goals.
    • design.md — the mechanism authority: path model (app-level frozen map + per-profile slot), profile registry (profiles.json), boot sequence (boot = first activation, two tiers), activation ADT with convergent rollback, per-profile DbService, switch orchestration, renderer reset, service classification, invariants table, the profile management surface (§13), and the switch-window context fence (§14).

Fixes #

Why we need it and why it was done in this way

Users need multiple mutually invisible local environments in one app (work/personal separation, clean experiment environments, per-context provider/assistant/knowledge configurations). The current frozen single data root makes this impossible without restarting. This PR lands the design for review; implementation follows in separate PRs.

The following tradeoffs were made:

  • Database-level isolation (one SQLite file per profile) over row-level discriminator columns or ATTACH schemas: full physical isolation and profile-agnostic business code, at the cost of per-profile migrate/seed and disk usage.
  • A dedicated ProfileActivatable axis instead of lifecycle stop/start: stop tears down onInit-registered IPC and cross-phase dependencies are invisible to stop cascades; the activation contract is repeatable, awaited, and symmetric.
  • Namespace isolation, not a security boundary: no auth/encryption; single instance = single writer per profile. Stated explicitly in the RFC.

The following alternatives were considered:

  • Rebinding Electron userData per profile (setPath is preboot-only), fine-grained renderer cache invalidation instead of window reload (leak-prone, deferred), full process relaunch on switch (violates the no-restart goal). See RFC §6.

Links to places where the discussion took place: internal RFC review (Feishu)

Breaking changes

None — documentation only.

Special notes for your reviewer

  • RFC records what will not change (intent, decisions, non-goals); design.md tracks mechanism and evolves with the implementation.
  • design.md §13 (profile management) and §14 (switch-window context fence) are designed-ahead sections whose implementation lands in follow-up PRs.

Checklist

This checklist is not enforcing, but it's a reminder of items that could be relevant to every PR.
Approvers are expected to review this list.

  • Branch: This PR targets the correct branch — main for active development, v1 for v1 maintenance fixes
  • PR: The PR description is expressive enough and will help future contributors
  • Code: Write code that humans can understand and Keep it simple
  • Refactor: You have left the code cleaner than you found it (Boy Scout Rule)
  • Upgrade: Impact of this change on upgrade flows was considered and addressed if required
  • Documentation: A user-guide update was considered and is present (link) or not required. Check this only when the PR introduces or changes a user-facing feature or behavior.
  • Self-review: I have reviewed my own code (e.g., via /gh-pr-review, gh pr diff, or GitHub UI) before requesting review from others

Release note

NONE

RFC records intent and decisions: motivation, database-level isolation, the
ProfileActivatable activation model, switch semantics, threat/concurrency model,
observability contract, and non-goals. design.md is the mechanism authority:
path model, profile registry, boot sequence, activation ADT, DbService, switch
orchestration with convergent rollback, renderer reset, service classification,
invariants, profile management (§13) and the switch-window context fence (§14).

Signed-off-by: defi-failure <159208748+defi-failure@users.noreply.github.com>
@0xfullex

0xfullex commented Jul 3, 2026

Copy link
Copy Markdown
Member

Overall

The design is well-reasoned and lands the three hardest decisions on solid ground (see "What's solid" below). My review centers on one load-bearing decision — the "no restart" goal — plus two concrete correctness fixes and one rationale fix. I'd treat the first as a decision to make explicitly before merging the design, since ~half the mechanism (§8/§9/§14) exists only to serve it.

1. The load-bearing question: is a zero-restart, top-to-bottom hot-switch actually achievable? (please reconsider)

The RFC treats "switch at runtime, no restart" (§2) as a given, and it's what forces the runtime machinery: §8 reverse-order deactivate + convergent rollback, §9 renderer reset, and the entire §14 AsyncLocalStorage context fence. I don't think a clean zero-restart full reload is achievable on this platform, for two reasons that are platform constraints, not implementation effort:

  1. The renderer's web-storage location cannot be relocated at runtime. app.setPath('sessionData') is pre-ready only, and a live renderer's partition is immutable — Electron docs: "can only be modified before the first navigation, since the session of an active renderer process cannot change." So isolating per-profile localStorage/IndexedDB/partition-cookies requires recreating the renderer (new partition) — i.e. a renderer restart in all but name. clearStorageData is not a substitute (see item 3).
  2. In-flight async work has no airtight runtime fence. §14 exists precisely to catch "straddle" writes, and §14.7 itself enumerates loss points (EventEmitter cross-context, boot-period timers, native callbacks without AsyncResource, connection pools) where the fence silently fails. Only process death is a complete fence.

Net: since the renderer half must be recreated regardless, "no restart" really means "no main-process restart" — and the price for keeping the main process alive across the switch is §8 + §14, with §14's admitted holes. That's a large, leaky cost for a modest prize.

Industry check (adversarially verified sources): the apps that do full data-layer isolation either run concurrent instances (Chrome/Firefox: separate --user-data-dir per profile) or reload the renderer (VS Code Profiles). I found no verified precedent for a clean in-place full hot-swap under a single shared process. (Note: a "Slack = process-per-workspace" claim was checked and refuted, so it's not a counter-example.)

2. What a restart-based switch unlocks — and it reuses an existing seam

Relaxing "no restart" revives the option the RFC §6 rejected because it needed a restart. At preboot you can app.setPath('sessionData', <profile>/session) — which isolates all Chromium web storage (Local Storage, IndexedDB, Partitions/, cookies) in one move, while ~/.cherrystudio/ app-global state stays put. Combined with the existing per-profile DB/Data path slots, the switch becomes: write activeProfileIdapp.relaunch(). This deletes §14 entirely, reduces §8 to a file write + relaunch, and deletes §9.

Crucially this isn't new machinery: src/main/core/preboot/userDataLocation.ts already performs BootConfig-driven userData relocation with a relaunch-copy. The profile mechanism is a generalization of that existing seam ("pick 1 of N userData roots by activeProfileId"), which is further evidence that restart-on-switch is the natural fit here.

The honest costs of restart — ~1–3s latency and killing in-flight jobs/streams — are bounded: in-flight work needs draining/aborting on a hot-switch too (§14.6 keeps the drains), and §8 step 4 already re-arms recovery on activation. Restart is abort + boot-recovery, which is simpler and more testable than drain + fence + recovery. This matters only if profile switching is rare (the RFC's own mental model — work/personal, Feishu/Slack-style — is a few switches per session, not a hot loop). If switching is genuinely high-frequency, that's the one argument for keeping the hot-switch — worth stating explicitly if so.

Ask: state, in the RFC, whether "no restart" is a hard product requirement or an engineering preference. If there's no product reason that forbids a ~2s relaunch, I'd strongly recommend restart-on-switch + preboot sessionData rebind and dropping §8/§9/§14.

3. §9 renderer reset via clearStorageData is unsafe regardless of the above

Two independent problems:

  1. It destroys data users need kept. Mini-app logins live in the shared persist:webview partition cookies (WebviewContainer.tsx), and the renderer persist cache is localStorage['cs_cache_persist'] (CacheService.ts). Clearing on switch logs users out of every mini app and drops persisted UI state — so switching cannot preserve state.
  2. It doesn't even reliably clear. localStorage/IndexedDB are LevelDB-backed; deletes write tombstones, not byte removal, and clearStorageData is documented-unreliable for these (residue recoverable until compaction).

The correct shape is source isolation, not switch-time clearing: give each profile its own physical store (per-profile sessionData via restart — item 2 — or, if hot-switch is kept, persist:profile-<id> partitions with window recreation). Then switching preserves state and clears nothing. Please replace the §9 clear-based approach.

4. §3 — keep profiles.json as a separate file, but fix the stated rationale

The decision to use a dedicated ~/.cherrystudio/profiles.json (separate from boot-config.json) is correct, but the reason given ("registry is a list; BootConfig is flat KV") doesn't hold: BootConfig values are already structured objects (app.user_data_path: Record<…>, temp.user_data_relocation: {…}|null), so a profiles object would fit type-wise. The real reasons to keep it separate are stronger and worth stating instead:

  1. Corruption blast radius. BootConfigService.loadSync falls the whole file back to defaults on any parse error. BootConfig also holds the boot-critical app.user_data_path (where userData lives). Co-locating a profile registry means one bad write can lose the entire profile list and the userData pointer together. A dedicated file gives the per-entry validation §3 already wants ("drop bad entries, keep valid ones") and isolates the failure.
  2. Write-churn decoupling. Profile CRUD (create/rename/delete-with-tombstone/setActive) is frequent mutating writes; boot-config.json holds the app's most write-sensitive state. Keep churn off that file.
  3. Data nature. BootConfig is a settings KV (diff-from-default, delete-when-default, debounced, no deep-merge). The registry is a managed entity collection with versioning and crash-safe two-phase deletes — an entity store owned by ProfileService, not a setting.

(Also: don't split the scalar activeProfileId into BootConfig while keeping the list in profiles.json — that adds a cross-file referential invariant and loses the atomic single-file switch commit in §8 step 5. Co-locate pointer + list.)

What's solid

  • Library-level isolation (one SQLite file per profile, single-active) is the industry-recommended grain; the row-level profile_id rejection is right (SQLite has no native RLS; a dropped WHERE leaks).
  • The activation model (ProfileActivatable + container-keyed services, Profile as a handle) is exactly Chromium's ProfileKeyedServiceFactory pattern.
  • The "namespace isolation, not a security boundary" framing (§4) matches the verified industry consensus (the Signal Desktop plaintext-key lesson; safeStorage's boundary is the OS account, with no per-profile key isolation). No over-promise here — correct.

(If §13's deleteProfile credentials go through safeStorage, worth a one-line note that they're namespace-isolated, not key-isolated, to stay consistent with §4.)

…or, precedents

Revise per 0xfullex's review on #16716:
- §9 rewritten to source isolation: per-profile webview partitions
  (reload-remount, window never destroyed) + persist-cache key namespacing;
  clearStorageData and switch-time clearing removed entirely
- §8: flush-before-teardown in deactivate; rollback failure escalates to
  app.relaunch() into target (restart as the reliability floor)
- §13.1: partition-dir tombstone + registry-driven persist-key GC;
  safeStorage namespace-isolation note
- §3: registry rationale rewritten (blast radius / write churn / entity
  store vs settings KV); version field in type; base62 example fixed;
  missing-file log level split from corrupt-file
- §10: WebviewService/ProxyService join the list; audit candidates noted
- §14: transitional-guard provenance clarified; bare ipcMain.handle
  inventory corrected (~130, concentrated in ipc.ts)
- RFC §2 intent/frequency, §4 storage decision + relaunch floor,
  §7 precedents (Chrome in-process profiles, in-place-reload apps)

Signed-off-by: defi-failure <159208748+defi-failure@users.noreply.github.com>
@defi-failure

Copy link
Copy Markdown
Collaborator Author

@0xfullex Thanks for the review — genuinely useful, and two of the four points I'm adopting wholesale. I've revised the docs accordingly (6d0aa785ea), and since several of the platform questions here are directly testable, I put together a small runnable demo: https://github.com/defi-failure/partition-demopnpm install && pnpm test runs the assertions and prints a report; pnpm start is interactive with switch buttons. I've tried to keep the reasoning below self-contained, with section references only for the full mechanics.

1. The renderer-storage constraint, taken apart into its two populations

You distinguished two storage populations, and they are indeed different problems — addressing them separately:

(a) persist:webview — mini-app webviews and provider OAuth popups (the third-party login state). Partition immutability binds a live WebContents, not a window — and every consumer of this partition happens to be recreated on a main-window reload anyway: <webview> guests die with the page and remount (WebviewContainer.tsx:185), and OAuth popups receive their partition at open time via the window-open override (MainWindowService.ts:349). So per-profile partitions — persist:webview-<id>, with the default profile keeping persist:webview for the zero-migration invariant — work with a plain reload; the BrowserWindow is never destroyed. The demo asserts this end to end: a fresh partition is empty on first switch, and switching back restores the previous profile's localStorage + cookies intact — mini-app logins are preserved per profile, not lost. Session-level config on that partition (UA rewrite + Accept-Language hook in WebviewService, proxy in ProxyService) re-applies per profile session on activation; both services join the §10 list.

(b) The default session the app windows load in. You're right that a reload cannot re-home this storage — and the design no longer attempts anything of the sort. In v2 the renderer holds no business data by design; the sanctioned persistent use of the default session is CacheService's persist tier (localStorage['cs_cache_persist']). The fix is logical isolation: namespace the key per profile (cs_cache_persist:<profileId>). This also answers the allowed-to-lose-is-not-must-lose concern better than any clearing scheme could: each profile's layout survives under its own key and is restored on switch-back (also demo-asserted). Physical co-residency of the namespaced entries in one LevelDB store stays within the §4 threat model (namespace isolation, not a security boundary). Two hardening items so this is enforced rather than hoped: a one-time inventory of the remaining direct web-storage uses in the renderer (v1 residue is already on the deletion list; anything credential-shaped moves to main-process storage), plus a lint rule confining renderer web-storage access to CacheService.

Multi-window semantics are exercised in the demo too: a second window shares the default session (which is why the namespacing must be app-wide, not per-window), and on switch non-main windows are closed while the main window survives as the same BrowserWindow, asserted by id.

Net effect on your framing — "'no restart' really means 'no main-process restart'" — yes, exactly, and that is the claim: the renderer reloads, the window and the process survive. clearStorageData is gone entirely (your point 3, adopted in full — nothing is cleared; both populations are isolated at the source). On the delete path, the profile's partition directory joins deleteProfile's two-phase tombstone and is physically deleted at next preboot when no session is live — which sidesteps the LevelDB-tombstone unreliability you flagged — plus a registry-driven GC of orphaned namespaced keys at renderer boot (§13.1).

2. The fence's holes

Conceded — and repositioned rather than defended. The ordered deactivate + drains are the primary guarantee; the fence is defense-in-depth that turns residual straddle writes into loud errors instead of silent corruption. And §8 gains an explicit terminal fallback: any switch failure beyond rollback escalates to app.relaunch() into the target profile. So "only process death is a complete fence" is absorbed as the design's reliability floor — the worst case degenerates to exactly restart-on-switch, and the hot path is an optimization above that floor, not a replacement for it.

3. Timing

The demo's numbers — reload-switch 66–143 ms vs app.relaunch() → first webview paint 613–921 ms across runs on the same machine — are mechanism floors, not end-to-end switch times (the demo app has no real boot, so the relaunch figure especially is a lower bound). The useful part is the decomposition: the heavy app-level work is paid on both paths — reopening the target profile's DB, a full renderer boot plus first data fetch — so it cancels out of the comparison. What each mechanism uniquely adds: the hot path pays drains (near-zero when idle, bounded by the drain timeout), the relaunch path pays Chromium cold start + preboot + init of every service (not just the profile-bound ones) + window recreation. I'll attach real end-to-end numbers from the implementation before this graduates from draft.

4. Industry check — two additions

  • Chrome: the standard multi-profile flow (the profile switcher, not --user-data-dir) runs all profiles inside a single browser process — per the Chromium docs, profiles are subdirectories of one user data directory, and two running instances cannot share it — with runtime load/teardown via the same ProfileKeyedServiceFactory machinery you pointed to for the activation model. I found that encouraging: the concurrent form of in-process profile hosting is proven at Chrome scale, and a single active profile is a strictly smaller ask of the same service-binding shape.
  • The switch UX itself: the pattern among shipping apps is consistently in-place — Discord's account switcher reloads the client into the selected account (up to five accounts, per their FAQ), Teams reloads into the new tenant, VS Code reloads the window, and Slack/Feishu/Telegram/Notion keep sessions live (per their help docs). This design takes the reload shape with a single active profile — the simpler of the two forms for us.

5. Your ask: preference or requirement

A candid answer: the origin is an experience conviction — switching should feel like switching, not like the app dying. Checking it against product evidence, it holds up: in-place switching is the baseline users are calibrated to (see 4); a relaunch is a visible app death — tray vanishes, dock re-bounces, window arrangement resets, global shortcuts and the selection assistant go dark for the gap — which reads as "something heavy happened" rather than "I switched"; and per §8's identity/sync direction, switching trends toward routine — the case you noted where keeping the hot switch is justified. This is now stated explicitly in RFC §2. And feasibility still comes first: if the platform made a clean hot switch impossible, none of this would justify forcing it — the demo is my attempt to show it doesn't.

6. profiles.json rationale + safeStorage

Adopted as-is — your three reasons (corruption blast radius, write-churn decoupling, entity store vs settings KV) are simply better than mine and replace the "list vs flat KV" wording in §3. Pointer + list stay co-located to preserve the atomic single-file commit in §8 step 5. §13 gains the safeStorage note (namespace-isolated, not key-isolated, consistent with §4).

Demo report (10/10)
=== partition-demo report (darwin-arm64) ===

Scenario: two profiles, A and B.
- The <webview> ("mini-app") gets partition persist:demo-<profile>; a localStorage
  value + cookie written inside it stands in for a mini-app login.
- App windows run on the shared default session; cs_demo:<profile> is a per-profile
  namespaced key standing in for the renderer persist cache (UI layout etc.).
- Switch = close non-main windows + reload the main window; the BrowserWindow and the
  main process stay alive throughout. A final app.relaunch() leg provides the contrast.

[PASS] app windows share ONE default session: a key written in the 2nd window is readable in the main window
[PASS] switch closes non-main windows; the main window is NOT recreated — same BrowserWindow id, content reload only
[PASS] A->B: B's webview partition starts empty — A's mini-app login (localStorage+cookie) is invisible to B
[PASS] a switch does NOT swap the default session: a key written WITHOUT a profile namespace is still visible under B (the leak that per-profile namespacing prevents)
[PASS] per-profile namespaced keys on the default session isolate: B reads its own key as empty while A's value stays intact
[PASS] the two webview partitions write to two different on-disk directories
[PASS] B->A: A's webview login is restored exactly (localStorage + cookie)
[PASS] B->A: A's namespaced key (stand-in for persist cache / UI layout) is untouched
[PASS] contrast leg: app.relaunch() creates a new process (PID changed) — all switches above ran inside one live process
[PASS] after relaunch, A's webview data is still on disk (partition data persists across a process restart)

main PID run1 (constant across both switches): 48476 → run2 (after app.relaunch): 48552

timing: switch A->B via window reload : 125 ms
timing: switch B->A via window reload : 143 ms
timing: app.relaunch() -> webview loaded: 780 ms
  (NB: both numbers are mechanism floors, not end-to-end switch times — this demo app
   has no real boot or app-level work; a real app pays its own work on top of either path.)

webview partition stores: demo-data/Partitions/demo-a · demo-data/Partitions/demo-b
  (per-profile partition dirs — deleteProfile can tombstone + remove the directory at preboot)

RESULT: ALL PASS

@0xfullex

0xfullex commented Jul 4, 2026

Copy link
Copy Markdown
Member

Validated — the architecture is sound

Thanks for the thorough revision and especially the runnable partition-demo. I verified the changes landed (6d0aa785ea) and re-checked the platform constraints the demo exercises. Two places where you corrected me, conceded:

  • The webview-partition path works on a plain main-window reload — partition immutability binds the WebContents, not the window, so the guest remounts with the new partition while the BrowserWindow and process survive. My "renderer restart in all but name" overstated the cost.
  • The Chrome precedent is stronger than my framing: the in-app profile switcher hosts multiple profiles in one browser process via the same ProfileKeyedServiceFactory shape, so single-active is a strictly smaller ask.

As an architecture the design is basically sound — the two storage populations are correctly separated, clearStorageData is gone in favor of source isolation, and app.relaunch() as the reliability floor is the right synthesis. The flushStorageData() + cookies.flushStore() before any relaunch/rollback is a nice touch.

Issues found during validation (for the record / relevant if this graduates)

  • §14 residual — narrowed, not eliminated. The relaunch floor catches detected orchestration/rollback failures, and the fence turns detected straddles into loud errors → recovery. But a straddle that slips a §14.7 hole (ALS undefined → §14.5 lets it through) is by definition undetected — caught by neither the fence (didn't detect) nor the relaunch floor (not a switch failure); it lands as a silent cross-profile write. Drains + fence make that surface narrow (only work that escapes the drains and loses ALS context), but only process death closes it fully. Worth stating in §14 as "narrowed, not eliminated" rather than fully covered — so "the worst case degenerates to restart-on-switch" reads as true for detected failures specifically.
  • Default-session isolation is enforcement-based, not structural. The cs_cache_persist:<profileId> namespacing is clean for localStorage (key prefix), but it holds only as strongly as the lint rule + inventory you committed to. Make sure the inventory covers IndexedDB too — it's namespaced by database name, not a key prefix, so any surviving renderer IndexedDB use needs per-profile DB names, not just the one localStorage key. Any direct web-storage write that bypasses CacheService silently defeats the isolation.
  • The keep-hot-path decision should rest on the real end-to-end numbers. The demo's 125–780 ms are mechanism floors; you already flagged this and committed to real numbers before graduating. That's the right gate — the ~600 ms the hot path saves over a restart is exactly what has to justify the standing §8/§14 complexity.

But the prior question is demand — and for the community edition it doesn't hold

Stepping back from implementation quality to the requirement itself: a well-built mechanism that shouldn't exist still shouldn't exist, so ahead of any further polish the question is who needs this, in which edition.

Multi-profile is a real, structural requirement in the enterprise edition — shared/managed devices, tenant and role separation, contexts a single OS login can't cleanly partition. There the demand is genuine and this design is the right basis for it.

For the community edition it's a pseudo-need, on two grounds:

  • The community use cases are already served — better — by an existing capability. The RFC's motivations (work/personal separation, clean environments) are covered by OS-level multi-account, which additionally is a real security boundary; this design explicitly is not (§4). For an individual on their own machine, in-app profiles are a second, weaker, more expensive door to something the OS already does properly.
  • The cost is a permanent per-service tax, not a one-time addition. The RFC names it itself (§5): every profile-holding service must correctly implement bind/release or it leaks/bleeds — and that obligation extends to every future service, not just today's twelve (§10). "Additive, breaks nothing" is exactly how a standing tax gets in: it makes the whole codebase profile-aware forever to serve a demand the community edition doesn't have.

Decision

Architecture — sound. But we won't be introducing multi-profile into the community edition. The design and the demo aren't wasted — they're the right foundation for the enterprise track, where the demand is real. Rather than merge into community main, let's carry this forward there.

Thanks again — the engineering is genuinely strong; this is a scoping call about where it belongs, not how well it's done.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants