Tenants & Organizations — Implementation Plan
Status: Draft v1 · Owner: Engineering · Date: 2026-05-27 Spec: spec.md · Tasks: tasks.md · Acceptance: acceptance.md
This plan is additive. Every step adds a column, a table, an endpoint, or a UI surface. Nothing existing is removed, renamed, or refactored. Existing users keep working without any data migration applied to them — they simply have
tenantId = NULLuntil they create their first Organization.
The plan is 10 phases. Each phase is a JIRA Story (Story keys assigned at ticket creation — see tasks.md for the linkage). Each phase ships as one PR against develop unless noted.
Phase 0 — Username uniqueness contract (foundation)
Goal: Make users.username uniqueness an enforced DB-level contract before anything else relies on it for slug routing.
Changes:
- New TypeORM migration:
AddUniqueIndexToUsername.- Pre-check:
SELECT username, COUNT(*) FROM users GROUP BY username HAVING COUNT(*) > 1— fail the migration loudly with a clear message if any duplicates exist (operator decides resolution). No live users yet, so we expect zero. - Add a UNIQUE index on
lower(username)(Postgres expression index for case-insensitive uniqueness). - SQLite fallback: plain UNIQUE on the raw column (covers the better-sqlite3 internal-cli test driver — see
database-migrations.md).
- Pre-check:
- New TypeORM migration:
AddSlugToUsers.- Add nullable
slugvarchar column. - Backfill
slugfromusername(URL-normalize per spec.md §3.3) for every existing user. - Add UNIQUE index on
lower(slug). - Flip column to NOT NULL after backfill.
- Add nullable
- New service:
apps/api/src/users/username-allocator.service.ts.- Public method
allocateUsername(base: string): Promise<string>— runs the existing suffix-on-collision loop in one place. Replace the inline loop ingithub-app-onboarding.service.ts:223-229with a call to this service. - Public method
allocateSlug(base: string, ownerTable: 'users' | 'organizations'): Promise<string>— same loop, checks bothusers.slugandorganizations.slugfor collisions. Used by the eventual Org-create path too.
- Public method
- New API endpoint:
GET /api/users/check-username?value=<string>.- Public (
@Public()). - Throttled.
- Returns
{ available: boolean, suggestion?: string }. - Used by interactive UI signup / settings forms.
- Public (
- Update entity:
user.entity.ts— addunique: trueto@Column()forusername; add@Column({ unique: true }) slug: string;(matches migration).
Out of scope this phase: any Tenant / Organization tables; any UI changes; any other entity changes.
Tests:
- Unit:
UsernameAllocatorServicehandles collisions deterministically. - Integration:
GET /check-usernamereturns suggestions and matches subsequent create behavior. - Migration: dry-run on a snapshot — no duplicates, clean apply.
Phase 1 — Create tenants and organizations tables
Goal: Land the two new tables. Empty on first deploy. No rows are written by this phase.
Changes:
- New entity:
packages/agent/src/entities/tenant.entity.ts.- Columns per spec.md §1.1.
- Unique index on
ownerUserId. - Unique index on
lower(slug).
- New entity:
packages/agent/src/entities/organization.entity.ts.- Columns per spec.md §1.2.
- FK
tenantId→tenants(id)ON DELETE CASCADE. - FK
linkedWorkId→works(id)ON DELETE SET NULL. - Unique index on
lower(slug)— globally unique across the table. - Composite index on
(tenantId, createdAt)for switcher list queries.
- New repository:
TenantRepository,OrganizationRepository(underpackages/agent/src/database/). - New TypeORM migration:
CreateTenantsTable. - New TypeORM migration:
CreateOrganizationsTable. - Register both entities in
packages/agent/src/entities/index.tsand the appropriate ORM module.
Out of scope this phase: any FK-back from existing tables to these new tables (that's Phase 2+). No backfill. No API endpoints yet.
Tests:
- Schema test: both tables exist, indexes are present, FKs cascade correctly.
- Entity test (alongside existing
work.entity.spec.tspattern): constructor + ClassToObject round-trip.
Phase 2 — Add tenantId to users; add tenantId to Tier B entities
Goal: Wire the User → Tenant FK and add tenantId to all auth-scoped Tier B entities (tenantId only — no organizationId for these).
Changes:
- New TypeORM migration:
AddTenantIdToUsers.- Add nullable
tenantId uuidcolumn tousers. - Add FK to
tenants(id)ON DELETE SET NULL. - Add index.
- Also adds
users.lastScopeOrganizationId uuid(nullable, FK toorganizations(id)ON DELETE SET NULL). This is the "remember the user's currently-active scope" column referenced by spec.md §5.6 — NULL means bare Tenant. Added in the same migration to keep all User-scoped FKs in one place. - No backfill. Existing users stay
tenantId = NULLandlastScopeOrganizationId = NULL.
- Add nullable
- New TypeORM migration:
AddTenantIdToTierBEntities.- Adds nullable
tenantId uuidcolumn + FK + index to each of:auth_accountsauth_sessionsauth_verificationsrefresh_tokensuser_template_preferencesuser_task_counters
- No backfill. Existing rows stay
tenantId = NULL.
- Adds nullable
- Update entities to add the column (
@ManyToOne(() => Tenant, { nullable: true })).
Out of scope this phase: writing tenantId on new inserts (that's Phase 5 — only after Tenant rows exist, which only happens after the lazy upgrade flow lands). Until then, tenantId stays NULL on all new auth rows too.
Phase 3 — Add tenantId + organizationId to Tier A entities
Goal: Add both columns to all top-level business entities.
Changes:
- One TypeORM migration per entity (following the
1779977000000-AddWorkOrganizationId.tstemplate):missions— add bothtenantId+organizationId, both nullable, both indexed.work_proposals(Ideas) — add both.tasks— add both.agents— add both.skills— add both.conversations— add both.notifications— add both.api_keys— add both.templates— add both.template_customizations— add both.user_subscriptions— add both.work_schedules— add both.work_deployments— add both.onboarding_requests— add both.webhook_subscriptions— add both.github_app_installations— add both.github_app_user_links— add both.works— addtenantIdonly (organizationIdalready exists; upgrade to FK in Phase 4).work_knowledge_documents— addtenantIdonly (organizationIdalready exists; upgrade to FK in Phase 4).
- For each: update the corresponding entity file to declare the columns.
- No backfill in any of these migrations. Existing rows stay NULL.
PR scope guidance: these can all ship in one PR (additive, low risk), or be split into 2–3 PRs grouped by domain (auth/work/agent/task/etc.) if the diff is too big for review. Editor's choice.
Tests:
- Per-entity migration test: apply + re-apply is idempotent (matches the existing
hasColumnguard pattern). - Entity test: new columns are nullable, optional in
ClassToObject.
Phase 4 — Upgrade existing free-form organizationId columns to FK
Goal: Now that organizations(id) exists, fix the existing forward-looking columns.
Changes:
- New TypeORM migration:
UpgradeWorkOrganizationIdToFk.- Pre-check: count rows where
organizationId IS NOT NULL(expect 0 — we haven't created any Orgs in DB yet). - If any non-NULL orphan UUIDs exist (no matching
organizations.id), NULL them out with a logged warning. - Add FK constraint
works.organizationId→organizations(id)ON DELETE SET NULL.
- Pre-check: count rows where
- New TypeORM migration:
UpgradeWorkKnowledgeDocumentOrganizationIdToFk— same pattern. - Update
work.entity.tsandwork-knowledge-document.entity.tsto declare the relation (@ManyToOne(() => Organization, ...)).
Phase 5 — Tier C children: denormalize tenantId (and organizationId)
Goal: Add denormalized scope columns to all Tier C children (per user-confirmed decision in spec.md §2.3).
Changes:
- One TypeORM migration (or batch — same rationale as Phase 3 splitting):
- For each Tier C entity, add nullable
tenantId uuid+ FK + index. - For Tier C entities whose parent is a Tier A object that also has
organizationId, add nullableorganizationId uuid+ FK + index. - Tier C list:
conversation_messages,task_assignees,task_approvers,task_reviewers,task_watchers,task_blocks,task_chat_messages,task_kb_mentions,task_attachments,task_relations,agent_runs,agent_run_logs,agent_budgets,agent_memberships,skill_bindings,work_members,work_invitations,work_generation_history,work_knowledge_chunks,work_knowledge_citations,work_knowledge_tags,work_knowledge_uploads,webhook_deliveries,usage_ledger_entries,plugin_usage_events,activity_log.
- For each Tier C entity, add nullable
- Update each entity to declare the columns.
- Service-layer change: every create path that writes a Tier C row must set
tenantIdandorganizationId(if the parent has one) from the currently active scope context. This is the largest code change in this phase — ~20–30 services touched.- Introduce a
ScopeContext(request-scoped NestJS provider) carrying{ tenantId: string | null, organizationId: string | null }. Resolver middleware (Phase 7) populates it. - Update every
Repository.create()/Repository.save()call site for Tier C rows to consumeScopeContext. - For background jobs / agent ticks / scheduled tasks: extract scope from the parent entity being processed and propagate.
- Introduce a
- No backfill in this phase. Existing Tier C rows stay NULL. Backfilled on the user's first-Org-upgrade (Phase 6).
Risk: missed service paths leave new rows with NULL scope. Mitigation: add a test that asserts new rows created via every service have tenantId set when the actor has a Tenant. Lint rule + code review.
Phase 6 — Lazy upgrade flow + Organization-create API
Goal: Implement the §5.2 / §5.3 flow — server-side creation of Tenants and Organizations + backfill.
Changes:
- New API endpoints:
POST /api/organizations— body{ name, slug? }. Creates Organization. If user has no Tenant, creates Tenant lazily. Returns the Organization row + scope info.POST /api/organizations/:id/upgrade-from-account— moves the user's existing Tier A/C rows from Tenant-root into this Organization (setsorganizationId), and writestenantIdon all Tier A/B/C rows that don't yet have one. Gated: only runs if this is the user's first Organization (i.e.organizations.count(tenantId = user.tenantId) === 1ANDorganizations.id === :id). Returns 409 Conflict if called after the user has created additional Orgs. Idempotent on the same first Org.GET /api/organizations— list all Orgs for the current user's Tenant.GET /api/organizations/:slug— fetch one by slug (used by the slug resolver middleware).PATCH /api/organizations/:id— update displayName, legalName, etc.GET /api/organizations/check-slug?value=<string>— uniqueness check acrossusers.slug+organizations.slug.
- New service:
apps/api/src/organizations/organization.service.ts.createOrganization(userId, name, slug?):- Lazy-create Tenant if
user.tenantId IS NULL. - Allocate slug via
UsernameAllocatorService.allocateSlug(name, 'organizations'). - Insert Organization row.
- If
users.lastScopeOrganizationId IS NULLand this is the user's first Org, store the new Org id onusers.lastScopeOrganizationIdso the next login lands there. - Unconditional
tenantIdbackfill (runs regardless of which branch — Upgrade or Empty — the user picks later): for every Tier A/B/C row owned by this user wheretenantId IS NULL, UPDATE to settenantId = newTenant.id. Same transactional shape asupgradeFromAccount's table walk, but writes onlytenantId. This makes the "Empty" branch (spec.md §5.2 3b) coherent even though it never callsupgrade-from-account.
- Lazy-create Tenant if
upgradeFromAccount(userId, organizationId):- Verify ownership (user owns Tenant; Org belongs to that Tenant).
- Verify Org's tenantId matches user's tenantId.
- First-Org guard: the endpoint is only callable while the user has exactly one Organization under their Tenant AND
:organizationIdis that one Org. Concretely:SELECT COUNT(*) FROM organizations WHERE tenantId = :userTenantIdmust equal 1, and the single row'sidmust equal:organizationId. Either condition failing → throw409 Conflictwith error codeUPGRADE_NOT_AVAILABLE_AFTER_MULTIPLE_ORGS. This prevents retroactively pulling items into a non-first Org. - Tier A rows (entities that have
organizationId+ directuserId): UPDATE every row whereuserId = X AND tenantId IS NULL→ set BOTHtenantId = newTenant.idANDorganizationId = newOrg.id. Rows already migrated to a Tenant are left as-is (idempotency).Tier C deferred to Phase 6b follow-up.Phase 11 follow-up shipped 2026-05-28 (EW-663) — Tier C is now handled by the sameupgradeFromAccountcall; see Phase 11 below. - Tier B rows (entities without
organizationId): UPDATE every row whereuserId = X AND tenantId IS NULL→ settenantId = newTenant.idONLY. Do NOT attempt to writeorganizationId— the column does not exist on these tables and the UPDATE would fail. - This is a transaction; the table list is enumerated in code (one UPDATE per table, all under one DB transaction). On Postgres, set
SET LOCAL statement_timeout = '60s'for safety (bumped from'30s'in Phase 11 to give the new Tier C join walks headroom).
- New service:
apps/api/src/scope/scope-context.service.ts— the request-scoped provider from Phase 5. - New service:
apps/api/src/scope/tenant-bootstrap.service.ts—ensureTenant(userId): Promise<Tenant>. Lazy creation logic in one place.
Tests:
- Unit:
OrganizationService.createOrganizationhandles no-Tenant, has-Tenant, slug-collision, name-empty cases. - Integration:
POST /api/organizationsend-to-end against an in-memory DB. - Integration:
upgrade-from-accountmoves expected rows, leaves nothing behind, second call is a no-op.
Phase 7 — Slug routing middleware + scope context propagation
Goal: Make /{slug}/... route shapes work on the API and the web app.
Changes:
- API (NestJS):
- New middleware:
apps/api/src/scope/scope-resolver.middleware.ts. Reads:slug(orX-Scope-Slugheader for fetch-from-web-client requests), resolves to{ tenantId, organizationId | null }, populatesScopeContext. 404s on no hit. - Apply globally on
/api/*routes that are scope-sensitive (most of them — exempt list:/api/auth/*,/api/users/check-username,/api/organizations/check-slug).
- New middleware:
- Web (Next.js App Router):
- New segment:
apps/web/src/app/[slug]/...— mirrors the existing dashboard tree. - Slug validated server-side in the layout; on 404, renders a not-found page.
- Client-side fetcher adds
X-Scope-Slugheader so the API sees the same scope on every call.
- New segment:
- Legacy routes (un-prefixed) — keep them. They resolve to the bare Tenant context for the session's user. Both shapes coexist (additive — NN #20).
Test surface:
- E2E:
/{username}/missionsrenders the same data as the legacy/missionsfor a user with no Org. - E2E:
/{orgSlug}/missionsrenders only that Org's data; switching the slug in the URL switches the data. - 404 path:
/notarealslug/missionsreturns 404, not 403, not 500.
Phase 8 — WorkspaceSwitcher UI (sidebar-07 reskin)
Goal: Ship the user-facing switcher.
Changes:
- New component:
apps/web/src/components/layout/WorkspaceSwitcher.tsx.- Base: shadcn
sidebar-07TeamSwitcher, renamed and re-skinned. - Reads
organizationsfrom a SWR hook (useOrganizations()) backed byGET /api/organizations. - Empty state: if
organizations.length === 0, render the existing<EverWorksLogo />component unmodified (no chevron, no popover trigger). - Active state: render the switcher chip with avatar + display name + chevron.
- Popover heading:
"Organizations"(i18n key TBD —organizations.switcher.heading). - List item: avatar + display name + check icon for currently active.
- Footer item:
+ Create Organization→ opens<CreateOrganizationModal />(Phase 9).
- Base: shadcn
- Wire
<WorkspaceSwitcher />into the existing sidebar layout, replacing the standalone<EverWorksLogo />placement. The logo placement keeps showing the logo when zero Orgs exist; it just becomes a switcher chip when 1+ exist. - State sync on switch:
- Clicking an Org row in the popover → POST
/api/users/me/scope(or PATCH the user row) to persistlastScopeOrganizationId; navigate to/{newSlug}/dashboard. - Clicking the bare-Tenant row (if listed per spec.md §5.5 option b) → same, with
organizationId = null; navigate to/{userSlug}/dashboard.
- Clicking an Org row in the popover → POST
Out of scope this phase: the Create Organization modal itself (Phase 9 — but Phase 8 still ships the entry point that triggers it; the modal can be a stub for the first PR if the team prefers to ship in two PRs).
Phase 9 — CreateOrganizationModal + upgrade-vs-new dialog
Goal: Ship the modal flow from spec.md §5.2.
Changes:
- New component:
apps/web/src/components/organizations/CreateOrganizationModal.tsx.- Single input:
Name. - Live slug preview (debounced
GET /api/organizations/check-slug?value=<derivedSlug>). - Submit → POST
/api/organizations→ returns the new Org.
- Single input:
- New component:
apps/web/src/components/organizations/UpgradeOrCreateDialog.tsx.- Renders only if this is the user's first Organization (gate:
(await GET /api/organizations).length === 1). - Two options:
- Upgrade current account (default, pre-selected, focused).
- Create with empty data.
- Confirm → if "Upgrade", POST
/api/organizations/:id/upgrade-from-account. If "Empty", do nothing (the Org was already created, it just stays empty). - In both branches: navigate to
/{newOrgSlug}/dashboardand refresh state.
- Renders only if this is the user's first Organization (gate:
- Subsequent Org creates (when the user already has 1+ Orgs) skip the dialog entirely — just create + navigate.
Tests:
- Unit:
UpgradeOrCreateDialogdefaults to "Upgrade". - E2E (Playwright): full flow — open switcher, create Org, choose Upgrade, verify existing items show up in new scope.
Phase 10 — Company chip on + New page + Work-of-type-Company → Org wire-up
Goal: Make the "Register Company" path (Stripe Atlas etc.) create an Organization on success.
Changes:
- Add
Companyto the chip list on the+ Newpage (per spec.md §6.3). Order:Mission · Idea · Website · Landing Page · Store · Blog · Directory · Awesome Repo · Knowledge Base · Company. (Note:StoreandCompanywere already coming-soon chips in earlier copy — they become real here.) - New Work template/type:
Company. Plumbing follows existing template/type patterns (Templates+WorkType). - New service hook: when a Work of type Company transitions to "registered" status (provider callback or manual marking), the system:
- Creates an Organization row with
legalName,countryCode,registrationProvider,registrationStatus = 'registered',linkedWorkId = work.id. - Sets
tenantIdto the user's Tenant (lazy-create Tenant if needed). - Triggers the
UpgradeOrCreateDialogflow client-side on the next page load (or via a notification → action handler).
- Creates an Organization row with
- The Work itself stays in its original scope. It just gains
organizationId = newOrg.idso it's visible in the new Org's scope as well as wherever it was created.
Phase 11 — Tier C historical organizationId backfill (EW-663)
Shipped 2026-05-28. Closes the Tier C gap deferred from Phase 6 step 4.
Goal: Backfill tenantId + organizationId on historical (pre-upgrade) Tier C rows when the user runs upgrade-from-account. New Tier C inserts already auto-stamp via the Phase 5b ScopeStampingSubscriber; this catches the rows that predate the user's first Organization.
Changes (pure service-layer + SQL — no migration, no new columns, no new deps):
OrganizationService.upgradeFromAccountgained a third backfill stage after the Tier A + Tier B loops:- Direct-user Tier C tables (
agent_runs,skill_bindings,usage_ledger_entries,plugin_usage_events,activity_log) carry their ownuserIdcolumn, so they use the sameUPDATE ... WHERE "userId" = $3 AND "organizationId" IS NULLshape as Tier A. - Join-walked Tier C tables (the other 20) propagate scope from their Tier A parent via
UPDATE "child" SET "tenantId" = $1, "organizationId" = $2 FROM "parent" p WHERE "child"."<fk>" = p."id" AND p."userId" = $3 AND "child"."organizationId" IS NULL.
- Direct-user Tier C tables (
SET LOCAL statement_timeoutbumped'30s'→'60s'for join headroom on large tables.- New
tierCRowsUpdated: numberon theupgradeFromAccountresult +UpgradeFromAccountResponsecontract.
Tier C → parent FK walk (26 tables: 5 direct-user + 20 join-walked + 1 deferred):
| Tier C table | Parent table | FK column | Path |
|---|---|---|---|
| conversation_messages | conversations | conversationId | join |
| task_assignees | tasks | taskId | join |
| task_approvers | tasks | taskId | join |
| task_reviewers | tasks | taskId | join |
| task_watchers | tasks | taskId | join |
| task_blocks | tasks | taskId | join |
| task_chat_messages | tasks | taskId | join |
| task_kb_mentions | tasks | taskId | join |
| task_attachments | tasks | taskId | join |
| task_relations | tasks | taskId (source endpoint) | join |
| agent_budgets | agents | agentId | join |
| agent_memberships | agents | agentId | join |
| agent_run_logs | agent_runs | runId | join (after agent_runs is stamped) |
| work_members | works | workId | join |
| work_invitations | works | workId | join |
| work_generation_history | works | workId | join |
| work_knowledge_chunks | works | work_id (snake-case) | join |
| work_knowledge_citations | works | workId | join |
| work_knowledge_tags | works | workId | join |
| work_knowledge_uploads | works | workId | join |
| agent_runs | — (direct userId) | — | direct |
| skill_bindings | — (direct userId) | — | direct |
| usage_ledger_entries | — (direct userId) | — | direct |
| plugin_usage_events | — (direct userId) | — | direct |
| activity_log | — (direct userId) | — | direct |
| webhook_deliveries | webhook_subscriptions | subscriptionId | deferred — parent has no direct user FK (Phase 11b) |
Deviation from the original prompt mapping: agent_runs was expected to be join-walked via agentId, but the entity carries a direct userId so it moved to the direct-user path. agent_run_logs uses runId (not agentRunId). task_blocks uses taskId (its sibling FK is blockedByTaskId); task_relations uses taskId (sibling relatedTaskId). work_knowledge_* parent FK is documentId for the doc tables in some other contexts, but for scope ownership we walk the direct workId/work_id column straight to works (the KB-document tables themselves are not user-owned Tier A). webhook_deliveries is deferred because webhook_subscriptions has no direct userId (it uses accountId).
Tests: extended organization.service.spec.ts — Tier C UPDATE shapes + params, total tierCRowsUpdated, idempotency (second call = 0), and the 60s statement_timeout bump.
Cross-cutting concerns
Database safety
- All migrations are forward-only and additive (NN #16).
- No DROP / ALTER TYPE / data deletion anywhere.
- Pre-checks at the top of each migration to detect impossible states (e.g. duplicate usernames, orphan
organizationIdUUIDs); fail loud rather than silently corrupting. - Every PR with a TypeORM entity change ships the corresponding migration in the same PR (NN #16).
Tests
Each phase ships with:
- Migration tests (apply + revert idempotency).
- Entity tests (round-trip, ClassToObject).
- Service unit tests for new business logic.
- Integration tests for new API endpoints.
- E2E tests for new user-facing flows (Phases 7–10).
The bar: existing 26 agent test suites + 719 tests all keep passing. New phases add their own suites alongside.
Rollout
- No feature flag needed for v1. The columns are added but unused on existing data; the switcher is gated by
organizations.length === 0(no UI change for users who don't have Orgs). Forward-looking and safe. - Deploy in phase order. Each phase is a separately mergeable PR. The full work spans Phases 0 → 10.
CI / release gates
Refer to CI_RELEASE_GATES.md:
- Lightweight CI runs on every PR + release-branch push (typecheck, lint, agent test suite).
- Medium/heavy CI runs on
stage/mainpushes only. - All 10 phase PRs follow the standard
develop → stage → maincascade (NN #21).
Sequencing summary
| Phase | Title | Depends on | PR target |
|---|---|---|---|
| 0 | Username uniqueness contract | — | develop |
| 1 | Create tenants + organizations tables | 0 | develop |
| 2 | tenantId on users + Tier B | 1 | develop |
| 3 | tenantId + organizationId on Tier A | 1 | develop |
| 4 | Upgrade existing free-form organizationId to FK | 1, 3 | develop |
| 5 | Tier C denormalization | 1, 3 | develop |
| 6 | Lazy upgrade flow + Organization-create API | 2, 3, 4, 5 | develop |
| 7 | Slug routing middleware | 6 | develop |
| 8 | WorkspaceSwitcher UI | 6, 7 | develop |
| 9 | CreateOrganizationModal + upgrade-vs-new dialog | 6, 7, 8 | develop |
| 10 | Company chip + Work→Org wire-up | 6, 7 | develop |
| 11 | Tier C historical organizationId backfill | 5, 6 | develop |
Phases 3, 4, 5 can run in parallel after Phase 1. Phases 8, 9, 10 can run in parallel after Phases 6–7. Otherwise, sequential.