Sphere — 팀 / 프로젝트 공간
한눈에 보기. Sphere는 Workspace 안의 팀이나 프로젝트별 공간입니다. 예: "엔지니어링 팀 sphere", "마케팅 팀 sphere", "프로젝트 X sphere". 같은 회사 안에서도 부서별로 보는 자료를 나눌 때 사용합니다.
언제 sphere를 만드시나요. 같은 회사 사람들이지만 일하는 영역이 달라서, 검색할 때 다른 팀 자료가 안 섞이는 게 좋을 때입니다.
언제 새 workspace가 필요하시나요. 완전히 다른 회사이거나, 결제와 관리자가 분리되어야 할 때입니다.
개발자용 상세
A sphere is a sub-team or project space inside a workspace. Spheres are where people and agents collaborate on a focused body of work, like a research wiki, a customer engagement, an internal product, or a launch. They have their own membership, their own activity feed, their own folder tree, and per-folder memory that agents pull as context on every read.
workspace ← tenant (team, billing)
├── notes (personal garden)
└── sphere ← this page
├── members (own role table)
├── invitations (cross-workspace by design)
├── activity feed
└── folders (≤5 deep)
├── notes
└── memory.md ← agents see this on every note read
If two pieces of work need to be hard-isolated (different companies, different billing) → separate workspaces. If they need to share members and atom context but stay organisationally distinct → separate spheres in the same workspace.
Schema
spheres (migration 020):
| column | notes |
|---|---|
id |
UUID. |
slug |
Globally unique. The segment after /s/ in URLs. |
name, description |
Display fields. |
visibility |
public | private. Public spheres can be read by anyone in the workspace; private ones require membership. |
workspace_id |
Hard parent. RLS-enforced. |
created_by |
The owner at creation. |
sphere_members:
| column | notes |
|---|---|
sphere_id + user_id |
Composite PK. |
role |
owner | editor | writer | viewer. |
joined_at |
Audit. |
Sphere roles are not workspace roles. A workspace admin is not automatically a sphere owner. The distinction is intentional: spheres are smaller social units with their own ownership lifecycle (e.g., a research wiki belongs to a researcher, not the workspace admin).
sphere_invitations:
Token-gated, by design without RLS. Recipients may not be in the inviter's workspace yet — a workspace-scoped policy would break the accept flow.
Permissions
src/lib/spheres.ts exposes the canonical helpers:
| helper | rule |
|---|---|
canView(role, visibility) |
Public spheres viewable by anyone; private require any role. |
canCreate(role) |
owner, editor, writer can create notes. |
canEditNote(role, noteUserId, currentUserId) |
owner/editor edit any note; writer edits their own. |
canManageMembers(role) |
Owner only. |
canDeleteSphere(role) |
Owner only. |
RLS
Two policies, since mig-022:
spheres_access— visible if (workspace match) or (visibility = public) or (caller is a member, resolved viacurrent_user_sphere_ids()).sphere_members_access— visible if (sphere is one ofcurrent_user_sphere_ids()) or (row is the caller's own membership).
This matters because a sphere's workspace is not always the caller's default workspace. The membership branch is what lets a user see spheres they belong to elsewhere.
withSphereContext(slugOrId, userId, fn, opts?) is the canonical entry point for any route or page that operates on sphere content. It:
- Resolves the sphere under the caller's user context (so RLS can use the membership branch even when the sphere lives in a foreign workspace).
- Runs
canViewand the optionalrequireRolecheck. - Re-enters the sphere's
workspaceIdviawithWorkspaceand runsfnthere. Sphere-scoped notes, folders, tasks, etc. are now visible to RLS.
Always treat a null return as "not found" rather than "forbidden" so sphere existence isn't leaked to outsiders.
Folders (mig-028)
A sphere has a tree of folders, up to 5 levels deep:
sphere
└── folder (depth 1)
└── folder (depth 2)
└── … (depth ≤ 5, enforced by trigger)
Hierarchy invariants are enforced at three layers:
- DB schema:
depth BETWEEN 1 AND 5,parent_id REFERENCES folders ON DELETE CASCADE, partial unique indexes on(sphere_id, slug)for root and(sphere_id, parent_id, slug)for nested. - DB trigger
folders_validate_parent: parent must be in the same sphere, depth must beparent.depth + 1, and the recursive walk catches re-parenting cycles. - App layer:
folders-repo.tsdoes the slug normalisation and rejects malformed names before insert.
The legacy notes.folder text column is deprecated but kept. Sync sources (Obsidian, Notion, Confluence) still post path strings; the server resolves them to a folder_id on save. A future migration will drop the text column.
Folder memory (mig-030)
Each folder gets a single memory.md blob — markdown the team curates, with a revision counter for safe concurrent updates.
GET /api/folders/:id/memory → { content, revision, updatedAt, updatedBy }
PUT /api/folders/:id/memory → { content, baseRevision }
GET /api/notes/:slug/memory-chain → [{folderId, name, depth, content}, …]
Concurrency model:
- Writers acquire
SELECT ... FOR UPDATEon the folder row, which queues concurrent writers per folder. - Inside the lock, the server compares the client's
baseRevisionto the currentmemory_revision. If they match → fast-path overwrite. If they differ → 3-way merge using the snapshot fromfolder_memory_history(bounded ring, pruned on write). - A merge that produces conflict markers (
<<<<<<< / ======= / >>>>>>>) returns{ merged: true, conflicts: N }so the client knows manual resolution is needed. - Latest content is mirrored on
folders.memory_mdso the common read path is a single SELECT.
Why agents care: when an MCP agent calls read_note, the server also returns folder_memory_chain — every memory.md from the root folder down to the leaf containing the note. Agents see this as required context. So the shape of the folder tree directly shapes the context an agent gets when working on any note inside it.
Activity feed
activity (mig-020) records sphere-scoped events: note created, comment added, member joined, etc. getSphereActivity(sphereId, limit) returns the per-sphere feed. listActivityForUser(userId, limit) is the home-page mashup across every sphere the user belongs to (admin SQL, intentional cross-workspace read — see the SOC2 comment in spheres.ts).
Comments
comments (mig-020) attach to a note inside a sphere. Read/write through getComments / addComment / deleteComment. Author-only delete.
Cross-workspace membership
A user can be a member of spheres whose workspace is different from their personal default. Two consequences for query code:
listUserSpheres(userId)runs insidewithUser(userId, ...)so thecurrent_user_sphere_ids()RLS branch fires across every workspace.- Most read paths must enter
withSphereContext, which re-wraps with the sphere's ownworkspace_id. Wrapping under the user's default workspace silently filters the sphere's content out via RLS.
Invitations
sphere_invitations rows are token-gated capabilities. The 24-byte base64url token is the auth gate; the table has no RLS so recipients can resolve invites before they're members.
Lifecycle:
POST /api/spheres/:id/invitations → createInvitation → { id, token }
GET /api/invite/:token → getInvitationByToken
POST /api/invite/:token/accept → acceptInvitationByToken
POST /api/invite/:token/decline → declineInvitationByToken
acceptInvitationByToken resolves and locks the invitation on the admin connection, then performs the membership write inside the sphere's workspace context (ensureSphereMembershipFromInvitation) so RLS and default workspace_id semantics still apply.
Email-only invites (recipient hasn't registered yet) are matched by (invited_email, invited_user_id) in the dashboard inbox flows (acceptInvitation/declineInvitation). Both paths gate on the caller's own user id or normalised email before any write.
Tasks inside a sphere
The task board (mig-023) is workspace-scoped, but each task can carry a sphere_id. MCP tools create_task and list_tasks accept sphere_slug / sphere_id to scope the inbox. Tasks without a sphere live at the workspace level.
Lifecycle
Manual create
POST /api/spheres
body: { name, slug, description?, visibility?, workspaceId? }
Accepts session or x-write-key. The route picks the workspace in this order:
- Explicit
body.workspaceId(caller must be a member of that workspace). - The workspace bound to the API key (
auth.workspaceId) — relevant foriri_ws_*keys. defaultWorkspaceForUserfor cookie-session callers without an explicit pick.
Slug rules: 3–40 chars, lowercase [a-z0-9_-], not in the reserved list (admin, api, dashboard, …). 409 on slug collision; 400 on bad slug. Rate-limited to 10 creates per hour per user.
Delete
DELETE /api/spheres/:id?confirm=<slug>
Two contracts stack:
- Owner-only via
canDeleteSphere— only sphere owners can delete. - Slug-echo gate — the request must include
?confirm=<slug>(or{confirm}in the JSON body) matching the sphere's actual slug; otherwise 400. Same contract as workspace deletion. The dashboard'sSphereSettings.tsxsends this; MCP'sdelete_sphereenforces it client-side too.
Unlike workspace deletion, there is no "must be empty" gate. Sphere children are all ON DELETE CASCADE:
| child | rows removed on cascade |
|---|---|
notes |
every note in the sphere (including drafts and tasks-as-notes) |
folders |
the whole folder tree |
folder_memory_history |
every memory.md revision (via folders cascade) |
tasks |
the sphere's task board |
sphere_members, sphere_invitations |
membership + open invites |
activity, comments |
feed + per-note comments |
api_key_spheres |
sphere-scoped API key bindings |
The slug echo is the trip wire. After it fires, the cascade is unconditional.
MCP
create_sphere and delete_sphere mirror the workspace lifecycle tools. See docs/mcp/tools.md for argument shapes and the destructive-confirm contract.
See also
- workspaces.md — the parent tenant.
- memory-layers.md — folder memory is a Layer-3-adjacent surface (curated context, not extracted atoms).
brainstorming/onboarding-fork-plan.md,brainstorming/task-board-design-session.md— design history.