Tenants & Organizations — Product Spec
Status: Draft v1 · Owner: Product (Ruslan) · Date: 2026-05-27 Audience: Product, Engineering (backend + frontend + AI), Design Internal codename: "Workspace foundation" Related code today:
usersentity,apps/api/src/auth/*,apps/api/src/onboarding/*users.usernamereferenced bygithub-app-onboarding.service.ts:223-229(existing suffix-on-collision pattern) andonboarding-account.adapter.ts:138-145(asserted DB UNIQUE constraint)Work.organizationIdalready exists as forward-looking nullable UUID column (work.entity.ts:96-116, migration1779977000000-AddWorkOrganizationId.ts)WorkKnowledgeDocument.organizationIdalready exists with the same forward-looking treatment (work-knowledge-document.entity.ts:62-67)- Spec §7.6 (referenced in
work.entity.tscomments) anticipated this entity
Scope of this document: product behavior — concepts, relationships, UX, UI, flows, states, naming. Implementation details are referenced where they constrain behavior. The phased execution plan lives in the sibling plan.md; the task checklist in tasks.md; acceptance criteria in acceptance.md.
Hard rule (NN #20 — additive by default): Nothing currently shipping is removed or rewritten. Every column added is nullable on insert and additive to existing tables. Every UI surface is layered on top of existing UI. Existing users keep working with zero migration: they have no Tenant and no Organization until they explicitly create one.
0. TL;DR
We introduce two new internal entities — Tenant and Organization — to give every user a scope they own and an optional sub-scope (or several sub-scopes) for registered companies.
User ─1───1─► Tenant ─1───N─► Organization
│
└─◄── (one Work of type Company can back each Org)
- Tenant — fully internal concept. Never appears in the UI. One per user, created on demand the first time the user creates an Organization. The default scope for everything the user owns.
- Organization — user-facing. UI label varies by context: in settings and switcher it reads "Organization"; in the new-item chips and Stripe-Atlas registration flow it reads "Company". Same row in DB either way. A Tenant can have zero, one, or many Organizations.
- Switcher — a small icon at the top-left of the sidebar (where the Ever Works logo lives today). Hidden while the user has zero Organizations; appears with chevron + Org logo/name once one exists. Cycles between the user's Organizations.
- URL slug —
user.username(URL-safe form) when the user has no Org active;organization.slugwhen an Org is active. Same path shape:/{slug}/missions/....
Existing users: no Tenant row, no Organization row, no tenantId set on their existing entities. Everything keeps working. The moment they create their first Organization and choose "upgrade current account", a Tenant is created for them, the Organization is linked to it, and their existing rows are backfilled with tenantId (and, for rows they want under the Org, organizationId).
1. The Two Concepts
1.1 Tenant (internal-only, NEVER shown in UI)
A Tenant is the user's private account scope. It is the always-present default container for everything the user creates.
- Cardinality: 1 User : 1 Tenant. The User table gains a nullable
tenantIdFK column. A Tenant row is created the first time the user creates an Organization (lazy creation — see §5.1). - UI visibility: none. The word "Tenant" never appears in any user-facing string. It is a backend / DB concept only. The user sees their account name (handle) or their Organization name, never the Tenant.
- Why this exists: every row in the system needs to know which user's scope it belongs to. Putting
userIdon every row works only as long as we assume 1:1 user:scope; the Tenant abstraction lets us evolve that later (e.g. shared workspaces, agency client scopes) without re-plumbing every entity. - Properties:
id(uuid)ownerUserId(uuid, unique, FK tousers.id)slug(varchar, mirrorsusers.usernamefor routing; unique)displayName(varchar; falls back to username)createdAt,updatedAt
1.2 Organization (user-facing — UI label varies)
An Organization is a sub-scope inside the Tenant — typically representing a registered legal entity (a Company), but it could also be an unincorporated team, a brand, a side project, anything the user wants to keep isolated from their main account.
- Cardinality: 1 Tenant : 0..N Organizations. A Tenant can have many Organizations. Each Organization belongs to exactly one Tenant.
- UI labels (same DB row, different wording per surface):
- "Organization" — in the switcher popover heading, in the Settings → Organization page, anywhere the user is managing scope.
- "Company" — in the
+ Newpage chips ("Mission · Idea · Website · Landing Page · Store · Blog · Directory · Awesome Repo · Company"), in Stripe Atlas registration copy, in the Work-of-type-Company flow. - When a Work of type Company is created and succeeds (e.g. Stripe Atlas registration completes), a corresponding Organization row is created (or linked) — see §5.4.
- Properties:
id(uuid)tenantId(uuid, FK totenants.id)slug(varchar, globally unique — coexists withusers.slugin the URL namespace, see §6.2)legalName(varchar; the registered name if it's a real Company)displayName(varchar; what shows in the switcher chip)countryCode(varchar(2), nullable)registrationProvider(varchar, nullable:'stripe-atlas' | 'manual' | …)registrationStatus(varchar:'draft' | 'pending' | 'registered')linkedWorkId(uuid, nullable, FK toworks.id) — the Work of type Company that backs this Organization, if anycreatedAt,updatedAt
- Logo / avatar: v1 uses a deterministic generated avatar from the slug initials (no upload). v1.1 may add upload to the Settings → Organization page.
1.3 "Company" vs "Organization" — display wording
The user explicitly chose: DB always says organizations / organizationId; UI says "Company" wherever it reads more naturally to a founder.
| UI surface | Wording |
|---|---|
+ New page chips | Company (alongside Mission, Idea, Website, etc.) |
| Stripe Atlas registration flow | Register Company |
| Settings page section | Organization |
| Switcher popover heading | Organizations |
| Switch chip in sidebar | display name of the selected Organization (no label prefix) |
| API endpoints | /api/organizations (technical name; not user-visible) |
| DB columns | organizationId |
This is a wording-only choice; no two entities, no two tables, no two API surfaces.
2. Relationships & Scoping Rules
2.1 Relationship diagram
2.2 Scoping rules for entities
Every business-level entity gains two new columns:
tenantId— required (NOT NULL) after the user's first Organization create (which triggers Tenant creation). Until then, existing rows haveNULL— see §5.1 lazy backfill. New rows on a user who already has a Tenant are written withtenantIdset on insert.organizationId— always nullable. NULL means "belongs directly to the Tenant, not to any specific Organization." Set means "scoped to this Organization."
A row's effective scope:
organizationId IS NOT NULL→ scoped to that Organization. The switcher must be on that Organization to see it.organizationId IS NULL AND tenantId IS NOT NULL→ scoped to the Tenant root. Visible when no Organization is selected (i.e. the bare account view).tenantId IS NULL→ legacy / pre-Tenant row. Visible in the bare account view of its owning user.
2.3 Three tiers of entities (which columns each tier gets)
Tier A — top-level business objects (both tenantId + organizationId)
Mission,Work,Task,Agent,Skill,WorkProposal(Ideas),Conversation,Notification,ApiKey,Template,TemplateCustomization,UserSubscription,WorkSchedule,WorkDeployment,OnboardingRequest,WorkKnowledgeDocument,WebhookSubscription,GithubAppInstallation,GithubAppUserLink- (*)
Work.organizationIdandWorkKnowledgeDocument.organizationIdalready exist as free-form UUID columns; they get upgraded to FK referencingorganizations.idin this work.tenantIdis added fresh.
Tier B — user-scoped but org-irrelevant (tenantId only)
AuthAccount,AuthSession,AuthVerification,RefreshToken,UserTemplatePreference,UserTaskCounter- No
organizationId— these are user-identity records, never scoped to a sub-org.
Tier C — children that denormalize tenantId (and organizationId where applicable) — per user decision
ConversationMessage,TaskAssignee,TaskApprover,TaskReviewer,TaskWatcher,TaskBlock,TaskChatMessage,TaskKbMention,TaskAttachment,TaskRelation,AgentRun,AgentRunLog,AgentBudget,AgentMembership,SkillBinding,WorkMember,WorkInvitation,WorkGenerationHistory,WorkKnowledgeChunk,WorkKnowledgeCitation,WorkKnowledgeTag,WorkKnowledgeUpload,WebhookDelivery,UsageLedgerEntry,PluginUsageEvent,ActivityLog- These rows denormalize
tenantIdfor cheap scoping/RLS-style queries without joins. - For
organizationId: denormalize when the parent has it (most do — TaskAssignee inherits from Task, etc.). - Service-layer responsibility: every create path sets
tenantId(andorganizationIdif parent has one) on insert. No backfill needed for new installs; lazy backfill on first-Org-upgrade for the user's existing rows (§5.1).
Tier D — global / system (neither column)
SubscriptionPlan(global catalog),Cache(system-level).
2.4 What an Organization "owns"
The user explicitly said: "each company might have own Works (e.g. few websites, apps etc) and also Agents and also Tasks associated with such company and Human employees etc."
So an Organization can directly own:
- Missions, Ideas, Works, Tasks, Agents, Skills (Tier A: any of these can carry
organizationId = thisOrg.id) - Knowledge documents (KB org-overlay)
- Human members (via
WorkMemberdenormalized withorganizationId— though full org-membership management is v1.1)
A Mission scoped to an Organization spawns Ideas scoped to the same Organization, which become Works scoped to the same Organization. The fan-out preserves scope automatically because every child inherits organizationId from its parent at create time.
3. Username — Uniqueness and URL Safety
Today's state (verified 2026-05-27):
user.entity.ts:40declares@Column()with nounique: true.github-app-onboarding.service.ts:223-229runs a suffix-on-collision loop usinguserRepository.findByUsername.onboarding-account.adapter.ts:140-145asserts a DB UNIQUE constraint exists, but no migration adds one and TypeORM's auto-sync may have created it inconsistently across environments.
This spec fixes the contract:
3.1 Database
Add a UNIQUE index on users.username (case-insensitive on Postgres via lower(username) expression index). Migration is straightforward — no live users with duplicates to resolve because the platform is not yet live. The migration adds a one-time guard: if duplicates exist, the migration fails loudly with a clear message; operator decides resolution.
Also add users.slug — a URL-safe denormalized form of username (lowercase ASCII, hyphens). Maintained on insert and on every username update via a service-layer hook. Unique index on users.slug (case-insensitive equivalent on Postgres).
3.2 Two flows for collisions
| Flow | Trigger | Behavior |
|---|---|---|
| Programmatic | OAuth callback, GitHub App install, social-auth registration, anonymous claim, any code path where the user is not interactively picking a username | Existing findByUsername + suffix loop (extract into shared UsernameAllocatorService). User never sees a "taken" error — they get ever, ever-2, ever-3 automatically. |
| Interactive UI | Username field on a signup or settings form | Debounced GET /api/users/check-username?value=... returns { available: boolean, suggestion?: string }. On collision, surface "username taken — suggested: ever-2" with the suggestion pre-filled in the input. User accepts or types another. The form blocks submit until the value is available. |
3.3 URL safety
users.slug is the URL-safe form. Generated from username via:
- Lowercase.
- Replace any non-
[a-z0-9-]with-. - Collapse runs of
-to a single-. - Strip leading/trailing
-. - If empty after normalization (degenerate username), fall back to
u-{first-8-chars-of-uuid}. - If the result collides with an existing
users.slugORorganizations.slug, append-2,-3, … (same suffix loop as username).
The slug only changes when the username changes (and the slug recomputation goes through the same allocator, so it never collides). No slug_redirects table (user-confirmed: not needed; rename history is out of scope).