Skip to main content

Member — collaboration model

Workspace + membership + invite. Companion to ../00-target-architecture.md (§4 domain/member, §9). Status: HISTORICAL — a review artifact from the re-architecture review, never updated after it landed. The umbrella migration SHIPPED on 2026-07-10 (../00-target-architecture.md), so every packages/{core,suite,run-case,billing} and apps/api/src/core/** path cited below names the pre-migration layout, not today's. Read it for the reasoning, not for the addresses.

Purpose & language

The tenancy backbone: Workspace (= tenant = trust zone; slug is the tenant key), Member (subject × workspace × role, roles viewer|member|admin cumulative), Invite (a hash-only, expiring, single-use join secret), active workspace (per-request selection of one membership), and Profile (display-only enrichment: name/avatar/email — never an authz input). Owner (workspace creator) is a distinct concept from the admin role: owner-only actions (workspace delete) bypass the role matrix entirely.

Language rules worth pinning:

  • last admin — the invariant subject: a workspace must always keep ≥1 admin.
  • bootstrap — lazily promoting a token/dev default workspace into a membership row.
  • consume — atomic invite redemption (claim + membership creation in one statement).

Aggregates & policies

classDiagram
class Workspace {
<<exists today - record>>
+id slug immutable
+name
+owner creator subject
+logoUrl
+createdAt
}
class MemberRecord {
<<exists today - record>>
+workspace
+subject
+role
+email display only
}
class MembershipPolicy {
<<exists today - domain policy>>
+assertNotLastAdminDemotion(ws, members, target, newRole)
+assertNotLastAdminRemoval(ws, members, target)
+assertCanLeave(ws, members, me)
-isLastAdmin(members, subject)
}
class Invite {
<<exists today - store rows only>>
+id
+role
+prefix inv_ hint
+tokenHash never plaintext
+expiresAt
+acceptedAt
+acceptedBy
}
class WorkspaceInviteStore {
<<exists today - INVARIANT OWNER>>
+consumeInvite(tokenHash, subject, email) ConsumeOutcome
+previewInvite(tokenHash)
+createInvite(input)
}
class MembershipService {
<<exists today - application service>>
+listMembers(ws)
+setRole(ws, subject, role)
+removeMember(ws, subject)
+leaveWorkspace(ws, subject)
+createInvite(input) plaintext once
+acceptInvite(principal, token)
}
class WorkspaceService {
<<exists today - application service>>
+create(subject, input) creator becomes admin
+update(ws, patch)
+delete(ws, subject) owner only
+listForSubject(subject)
}
class ActiveWorkspaceResolver {
<<proposed - extract from route-context>>
+resolve(principal, hint) Principal
}
MembershipService --> MembershipPolicy : guards mutations
MembershipService --> WorkspaceInviteStore : consume is delegated whole
MembershipService --> MemberRecord : orchestrates
WorkspaceService --> Workspace
WorkspaceInviteStore --> Invite : single CTE bumps count + inserts member
WorkspaceInviteStore --> MemberRecord : ON CONFLICT preserves role
ActiveWorkspaceResolver --> MemberRecord : membership lookup + bootstrap
note for WorkspaceInviteStore "Store-atomic SQL is the sanctioned home:\nthe CTE IS the atomic consume (reusable - no single-use lock).\nDomain declares semantics, contract tests pin them."

Target placement (00 §4): MembershipPolicy + invite-outcome semantics + the workspace slug/owner rules move to @everdict/domain member/; MembershipService/WorkspaceService become application/control use-cases; the invite CTE and ensureMembership SQL stay in infrastructure/persistence-pg as the invariant owner.

Lifecycle

Invite lifecycle (the only real state machine in this domain):

stateDiagram-v2
[*] --> active : createInvite (hash stored, plaintext shown once)
active --> active : consumeInvite CTE joins a member + bumps accepted_count (reusable, not single-use)
active --> expired : expires_at passes (lazy - checked in the CTE WHERE)
active --> revoked : revokeInvite (row deleted)
expired --> [*] : maps to 400
revoked --> [*] : indistinguishable from unknown - 404, no existence leak
note right of active : concurrent redeem - every caller joins; the row lock serializes accepted_count. A failed consume (expired/unknown) is classified by a read-only follow-up

Workspace: created (creator=admin, exactly once via ON CONFLICT DO NOTHING) → mutable display fields → deleted (owner-only hard delete). Membership has no state machine; role is a mutable attribute guarded by MembershipPolicy.

Key collaborations

Invite accept (the mandated sequence — store as invariant owner)

sequenceDiagram
participant T as HTTP route / MCP tool
participant S as MembershipService
participant ST as WorkspaceInviteStore (Pg)
participant M as everdict_workspace_members

T->>S: acceptInvite(principal, token)
S->>S: reject via != oidc (machine keys cannot join)
S->>S: hashKey(token)
S->>ST: consumeInvite(tokenHash, subject, email)
Note over ST,M: ONE CTE: UPDATE invites SET accepted_count = accepted_count + 1 WHERE unexpired RETURNING → INSERT member ON CONFLICT DO UPDATE SET email=COALESCE(...) — reusable; role never clobbered
ST-->>S: ConsumeOutcome ok | unknown | expired
alt ok
S-->>T: workspace + role (actual role read back, not the invite role)
else expired
S-->>T: BadRequestError 400
else unknown (revoked folds in)
S-->>T: NotFoundError 404 (no existence leak)
end
Note over T: target: reply = InviteAcceptResponse.from(result) (contracts/wire); today the route sends the service result verbatim

Member removal with cleanup hook

sequenceDiagram
participant T as route / tool
participant S as MembershipService
participant P as MembershipPolicy
participant W as WorkspaceStore
participant H as onMemberRemoved hook

T->>S: removeMember(ws, subject)
S->>W: listMembers(ws)
S->>P: assertNotLastAdminRemoval(ws, members, target)
P-->>S: ok or ConflictError 409
S->>W: removeMember(ws, subject)
S->>H: onMemberRemoved(ws, subject) best-effort (schedule auto-disable)
Note over S,H: hook failure swallowed — the store is the source of truth
S-->>T: void (idempotent: absent target returns silently)

Inbound use-cases

From the apps-api survey catalog (§1.9, #83–96):

#OperationTransportImplementationNotes
83List membersGET /members · list_membersMembershipService.listMembersprofile join (name/avatar) in service
84Set member rolePATCH /members/:subject · set_member_rolesetRole + MembershipPolicylast-admin demotion 409
85Remove memberDELETE /members/:subject · remove_memberremoveMemberidempotent; fires onMemberRemoved
86Leave workspaceDELETE /members/me · leave_workspaceleaveWorkspaceself-serve, no role gate; last admin 409
87Create invitePOST /invites · create_invitecreateInviteplaintext once; hash+prefix stored
88List / revoke invitesGET /invites · DELETE /invites/:idstore passthroughmeta only, never token_hash
89Accept invitePOST /invites/accept · accept_inviteacceptInviteOIDC-only; CTE consume
90Preview inviteGET /invites/previewpreviewInvitenon-consuming; token IS the auth
91List my workspacesGET /workspaces · list_workspacesWorkspaceService.listForSubjectself-serve
92Create workspacePOST /workspaces · create_workspacecreateslug + collision suffix; creator=admin
93Get / update workspaceGET/PATCH /workspaceget / updatename/logo; slug immutable
94Delete workspaceDELETE /workspace · delete_workspacedeleteowner-only, not in role matrix
95Who am IGET /me · get_profileroute composes list + profilecomposite read
96Update profilePATCH /me/profile · update_profileProfileService.updatedisplay metadata only
Active-workspace resolutionevery requestapplyActiveWorkspace (route-context)bootstrap + header switch + fallback

Outbound ports

PortTodayTarget owner
WorkspaceStore (workspaces + members, ensureMembership, roleFor)@everdict/db interfaceapplication/control port; Pg impl in persistence-pg
WorkspaceInviteStore (consumeInvite CTE, previewInvite)@everdict/dbsame — store stays the consume/reuse invariant owner
UserProfileStore (getMany join)@everdict/dbport
onMemberRemoved(ws, subject) hooklate-bound closure in main.ts (→ ScheduleService.disableByCreator)typed domain-event port (MemberRemoved)
Token primitives: generateInviteToken (inv_), hashKeyvalues exported from @everdict/dbdomain/member (issuance recipe) over a hash contract in contracts
validateImageRef (workspace logo)apps/api/src/common/image-ref.tsdomain/image

Rules: pre-migration → target

The left column is the 2026-07 layout, before this migration landed. It is an inventory of what moved, not a map of where anything is now — do not follow these addresses.

RuleToday (evidence)Target
Last-admin invariantONE owner already: apps/api/src/core/member/membership-policy.ts (3 intent-named guards over a passed-in member list)moves verbatim to domain/member — the model example of a policy object
Last-admin raceacknowledged read-modify-write race, apps/api/src/core/member/membership-service.ts:49 ("admin counts are small, allowed for v1")either keep + document, or add a store-atomic guard (UPDATE … WHERE (SELECT count(*) FROM members WHERE role='admin' AND …) > 1) — decide in review
Invite reuse (multi-use join link) + role preservationpackages/db/src/workspace/workspace-invites.ts (PgWorkspaceInviteStore.consumeInvite CTE — reusable, bumps accepted_count) and re-implemented in TS in InMemoryWorkspaceInviteStore.consumeInvitestore-atomic SQL is the sanctioned home; the InMemory copy becomes the contract-test double, pinned by a shared contract-test suite
Membership bootstrap + active-workspace switchapps/api/src/api/route-context.ts:201-237 applyActiveWorkspace — auth-domain policy in the transport-shared module (rule-sanctioned single owner today), incl. the role-capping rule (OIDC join to an existing workspace capped to member, :217-226)application/control request-context resolver (one use-case decorator), consumed by HTTP + MCP identically; the capping rule is domain/member policy
Creator-admin exactly once + no role clobberpackages/db/src/workspace/workspace-store.ts:226-232, 289-297 (ON CONFLICT DO NOTHING / email COALESCE) duplicated in the InMemory implstore-atomic; semantics declared in domain/member, pinned by contract tests
Owner-only workspace deleteapps/api/src/core/workspace/workspace-service.ts:55-65 — deliberately not in the role matrix (.claude/rules/auth.md)stays a domain rule (Workspace.assertOwner) in domain/member; never enters the matrix
Slug generation + collision suffixworkspace-service.ts:11-18, 69-94domain/member pure function (workspaceSlug)
Token hygiene recipe (hash-only, prefix hint, plaintext once)three parallel implementations: generateInviteToken (workspace-invites.ts:53), generateKey/issueKey (tenant-auth.ts:158-177), generateRunnerToken — same recipe, three copiesONE domain credential-issuance recipe parameterized by prefix (inv_/ak_/rnr_) — shared with the secret-key and runner domains
Role matrix mirror in webapps/web/src/shared/auth/can.ts re-types actions/roles for UI gatingweb imports contracts/wire types; per-resource allowedActions served in DTOs deletes the mirror
Profile enrichmentmembership-service.ts:32-46 (join of two stores)stays an application read-model concern; response DTO (MemberResponse.from) carries the joined shape

Invariants

InvariantOwnerPinned how
A workspace always keeps ≥1 admin (demotion / removal / self-leave)domainMembershipPolicy (3 guards, one predicate)unit tests pin the three messages; known benign race documented
An invite token redeems at most oncestore-atomic SQL — CTE UPDATE … WHERE accepted_at IS NULL … RETURNINGcontract test: concurrent consume, second gets {ok:false}; InMemory double must match
Redeeming never changes an existing member's rolestore-atomic SQL — member insert ON CONFLICT DO UPDATE SET email = COALESCE(...) onlycontract test: pre-existing admin accepts a viewer invite → stays admin (service also reads the actual role back)
Creator becomes admin exactly once; re-bootstrap never demotesstore-atomic SQLON CONFLICT DO NOTHINGcontract test on double-create
Plaintext invite/API tokens are never stored or listedstore discipline — hash-only columns; list queries never select token_hashcode review rule + tests asserting meta shape
Failure reasons never leak invite existence (revoked ≡ unknown → 404)domainacceptInvite outcome mappingroute tests pin status codes
via ∈ {runner, github-actions} principals never gain a member rowapplicationapplyActiveWorkspace early return (route-context.ts:206)tests in the auth suite

Open questions

  1. Harden the last-admin race with a store-atomic count guard now, or keep the documented v1 stance? (The CTE-consume pattern shows the shape it would take.)
  2. Where exactly does applyActiveWorkspace land — an application/control context resolver (proposed) or interface-kit middleware? It mixes identity (auth domain) with membership (member domain).
  3. Should onMemberRemoved become a general domain-event bus (member-removed also interests runner/CI-link domains) or stay a single typed hook?
  4. Workspace hard-delete today deletes the workspace row; scoped data cleanup across 17 stores is implicit. Does the target want an explicit cascade contract (per-store deleteTenant)?
  5. Invite expiry is lazy (checked at consume/preview). Is a sweeper needed, or is lazy expiry a pinned semantic?