Decision record
ADR-0005: Service members
ADR-0005: Service members
Status
Accepted — 2026-09-03. Required by PLAN Phase 9 §3 before that task is built.
Context
An MCP token today binds to a human. apiTokenSchema carries a userId, and
every request resolves getMember(db, token.workspaceId, token.userId)
(apps/mcp/src/index.ts), so an unattended system acts with a real person’s
authority and under their name.
Three consequences, all confirmed in the current code:
- Attribution is wrong. Every page, revision and activity row an
integration writes is authored by a person who did not write it. For
MarketPilot (
docs/integrations/marketpilot.md) the whole point of routing briefs through Bladbase is to evidence a human-review gate; an audit trail reading “Dustin drafted · Dustin approved” cannot support that claim. - Offboarding silently breaks integrations. Remove the member and the
token still authenticates (it is not revoked with the membership), but
getMemberreturns nothing and every call fails authorization — a confusing failure mode discovered in production rather than at removal. - Scope is not authority. A token scoped
readstill carries whatever workspace role its owner holds. An owner’s read-only token can read every page an owner can, which is more than the integration needs.
What we are not doing is the service-account path Phase 6 §2 rules out: a privileged identity that bypasses membership and policy. That remains rejected. The proposal here is the opposite — make the principal behind a token a first-class member, subject to exactly the same rules.
Two shapes were considered.
A. A separate principal type. New servicePrincipals collection,
membership rows gain a principalType, and every policy/attribution path
learns to resolve two kinds of id. Clean conceptually; touches every read
path that resolves a display name, plus principalsForUser, search ACLs,
comments, mentions, and the activity feed. It also splits the id space, so
actorId alone stops being resolvable without knowing which collection to
look in.
B. A kind discriminator on the existing user/member model. A service
member is a UserProfile with kind: 'service' and no email/password
credential, plus the ordinary WorkspaceMember row that already exists.
Everything that resolves an id keeps working unchanged; only the places that
create principals and render them need to know the difference.
Decision
Take shape B: one principal id space, discriminated by kind.
userProfileSchemagainskind: 'user' | 'service'(default'user', so every existing record is valid) and, for services,ownerUserId(who created it, for accountability) andsystemName(the external system it represents, e.g.marketpilot). A service profile has noemail— the field becomes optional, and Firebase Auth never issues a credential for it. Service ids are generated by us, never by Firebase Auth.- A service member is created only through a workspace-scoped use case
requiring
member.invite(admin floor): it creates the service profile, the membership row with an explicit role, and the token in one operation. The role is chosen at creation and is independent of the creator’s role — an admin can mint a service member that is only aneditor. apiTokenSchema.userIdkeeps its meaning and now points at the service principal. No change to token verification, hashing, or scope checks. Scopes continue to narrow what the token may do within the role; the role bounds it. Both must permit an action.- Every existing policy call site is unchanged.
canPerform(member, action)receives the same shape it always did, so a service member cannot do anything a human member of that role could not. - Attribution renders the service, not its creator. Display-name
resolution (activity feed, comments, revision authorship, member list)
shows the service’s
displayNamewith a distinguishing badge. This is what makes “MarketPilot drafted · Dustin approved” possible. - Revocation is membership removal, and pages survive it. Removing a
service member sets
status: 'removed'and revokes its tokens in the same batch (the mechanismsoftDeleteWorkspacealready uses). Its pages, revisions, comments and activity rows are untouched and keep referencing the service id, exactly as they do for a removed human — durable records store ids only, and names resolve at read time. A removed service renders with its storeddisplayName, not “a former member”: a service’s name is part of the audit trail, so unlike a person’s it is snapshotted at creation and never disappears. - Re-creating a service does not inherit the old one’s history. A new
service member is a new principal with a new id. Pages carrying an
externalRef(Phase 9 §1) are addressed by that ref, not by author, so a replacement service still upserts the right page — the identity of the content belongs to the external system, while the identity of the writer belongs to the principal. These are deliberately separate. - Service members never sign in. No session cookie path, no
email_verified, no/adminreachability. They exist only behind an API token. A service principal must never satisfylocals.user.
Consequences
- The member list gains a second kind of row, and the invite flow gains a
sibling: “Add a service member” producing a token shown once. The UI must
make the distinction obvious — a service member with
adminis a standing credential with admin authority. - Deletion-safe identity is preserved: nothing durable stores a service’s name except its profile, and that profile is retained after removal.
- Search ACLs and
principalsForUserneed no change, because a service id is an ordinary principal id. Group membership, when it lands, applies to services for free. kindmust be checked wherever we assume a principal is a person: session creation, mention resolution (a service should not be@mentionableuntil it can act on one), and any future email delivery — sending to a service member must be impossible, not merely unlikely.- The audit story improves for humans too: “which token wrote this?” remains unanswerable until activity rows carry the token id, which is a separate gap already noted in the feature review.
- If a future consumer needs a principal that spans workspaces, this decision
does not help — a service member is workspace-scoped by construction, and
cross-workspace identity is the
organizationslayer’s problem.
Alternatives rejected
- Shape A (separate principal type) — more faithful modelling, but it splits the id space and touches every resolution path for a benefit that is presentational. Revisit only if service principals need attributes that make no sense on a user profile.
- Reusing a human account as a “bot user” (create
bot@company.com, invite it) — works today with zero code, and is what people do in the absence of this feature. Rejected because it needs a real mailbox and a password to exist, it can sign in, and it is indistinguishable from a person in the UI and the audit trail, which defeats the purpose. - Per-token roles without a principal (put a role on the token) — simpler, and fixes consequence 3 alone. Rejected because it leaves attribution and offboarding broken, which are the two that matter for the evidence claim.
References
- Model Context Protocol specification — how a client presents its identity