Decision record

ADR-0005: Service members

Written by · decided

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:

  1. 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.
  2. Offboarding silently breaks integrations. Remove the member and the token still authenticates (it is not revoked with the membership), but getMember returns nothing and every call fails authorization — a confusing failure mode discovered in production rather than at removal.
  3. Scope is not authority. A token scoped read still 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.

  1. userProfileSchema gains kind: 'user' | 'service' (default 'user', so every existing record is valid) and, for services, ownerUserId (who created it, for accountability) and systemName (the external system it represents, e.g. marketpilot). A service profile has no email — the field becomes optional, and Firebase Auth never issues a credential for it. Service ids are generated by us, never by Firebase Auth.
  2. 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 an editor.
  3. apiTokenSchema.userId keeps 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.
  4. 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.
  5. Attribution renders the service, not its creator. Display-name resolution (activity feed, comments, revision authorship, member list) shows the service’s displayName with a distinguishing badge. This is what makes “MarketPilot drafted · Dustin approved” possible.
  6. Revocation is membership removal, and pages survive it. Removing a service member sets status: 'removed' and revokes its tokens in the same batch (the mechanism softDeleteWorkspace already 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 stored displayName, 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.
  7. 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.
  8. Service members never sign in. No session cookie path, no email_verified, no /admin reachability. They exist only behind an API token. A service principal must never satisfy locals.user.

Consequences

Alternatives rejected

References

Written and maintained by , who made this decision — about · GitHub.

← All decisions