iri 문서

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 via current_user_sphere_ids()).
  • sphere_members_access — visible if (sphere is one of current_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:

  1. 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).
  2. Runs canView and the optional requireRole check.
  3. Re-enters the sphere's workspaceId via withWorkspace and runs fn there. 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 be parent.depth + 1, and the recursive walk catches re-parenting cycles.
  • App layer: folders-repo.ts does 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 UPDATE on the folder row, which queues concurrent writers per folder.
  • Inside the lock, the server compares the client's baseRevision to the current memory_revision. If they match → fast-path overwrite. If they differ → 3-way merge using the snapshot from folder_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_md so 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:

  1. listUserSpheres(userId) runs inside withUser(userId, ...) so the current_user_sphere_ids() RLS branch fires across every workspace.
  2. Most read paths must enter withSphereContext, which re-wraps with the sphere's own workspace_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:

  1. Explicit body.workspaceId (caller must be a member of that workspace).
  2. The workspace bound to the API key (auth.workspaceId) — relevant for iri_ws_* keys.
  3. defaultWorkspaceForUser for 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's SphereSettings.tsx sends this; MCP's delete_sphere enforces 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.