ADR-011: Google Workspace lifecycle β tenant model, Admin SDK quota, billing
Renumbered from ADR-009 in #937 to resolve a duplicate-number collision with
009-ragflow-shared-instance-vs-per-tenant.md. No content changes.
Date: 2026-05-25 Status: Accepted β Phase 2 shipped 2026-05-29 via #1065 (OSA Gsuite lifecycle schema) + #1074 (WorkspaceLifecycleModule) + #1076 (WorkspaceProvisioningProcessor) + #1079 (OAuthTokenService) + #1089 (assignSpecialist hook) + #1093 (inbound email OSA routing) + #1095 (outbound Gmail Send) + #1097. Issue: #679 (this ADR); parent epic #678; consumed by #587 Authors: paulhumanity Revision history:
- 2026-05-25 (initial draft) β skeleton committed before tenant provisioning. Quota numbers cite published Google limits; final headroom calc pending real-tenant measurement. Billing line-items and incident runbook stubbed.
Contextβ
#678 commits us to per-(org Γ specialist) Google Workspace mailboxes in a single HP-owned tenant. Before any provisioning code lands (#680 and below), we need:
- The tenant itself standing up across staging + prod, with billing attached.
- Hard numbers on Admin SDK rate limits, so the provisioning hook (#680) is designed against a known ceiling rather than discovered at burst time.
- An auth-model decision (service account vs. domain-wide delegation) the rest of the workstream can build against without re-litigation.
- A billing posture (SKU, growth alerts) so unexpected user-count growth is detected before the invoice arrives.
- A workspace-side incident runbook (lockout, MX failure, suspension propagation) so Ops doesn't author it under fire.
Tenancy facts are settled in #678 and not re-litigated here:
- Per-(org Γ specialist) mailbox in a single HP-owned tenant.
- Address scheme:
{specialist-firstname}.{client-slug}@h.work(prod),β¦@h852.work(dev).h853.workreserved for staging. - Slug uniqueness is a platform-wide invariant.
- Plus-addressing rejected.
Decisionβ
1. Tenant layoutβ
| Environment | Domain | Tenant | Notes |
|---|---|---|---|
| Prod | h.work | HP-owned, primary | Production mailboxes only. |
| Staging | h853.work | HP-owned, separate | Mirrors prod auth model; TODO: provision. |
| Dev | h852.work | HP-owned, separate | Already in use as HWORK_DOMAIN for dev environments. TODO: confirm same auth-model as prod. |
Rationale for separate tenants per environment: dev/staging provisioning bugs must not be capable of touching prod billing or prod mailboxes. Domains are already split; tenants will be too.
2. Auth modelβ
Decision: service account with domain-wide delegation (DWD).
| Option | Pros | Cons | Verdict |
|---|---|---|---|
| Service account (no DWD) | Simplest, no impersonation surface | Cannot act as a specific user β can't send mail as bob.acme@h.work. Disqualified by #587 requirements. | β |
| OAuth user tokens per mailbox | Most granular consent surface, matches per-(Specialist Γ Client) scope cleanly | Requires interactive consent per mailbox at provisioning β incompatible with onboarding SLA. | β |
| Service account + DWD | One credential controls all provisioned mailboxes; Admin SDK and Gmail API both available; revocable per scope | Single high-value credential (mitigated by KMS encryption + rotation runbook); DWD grants are tenant-wide (mitigated by scoping to specific OAuth scopes, not all scopes). | β |
Scopes granted to the DWD client (full set is WORKSPACE_DWD_SCOPES in api/src/workspace-lifecycle/workspace-lifecycle.constants.ts β Specialists are treated as full personal Gsuite identities, minimum-necessary list because broader Google scopes subsume narrower ones, see architecture/workspace-lifecycle.md Β§Scopes for the subsumption notes):
admin.directory.user/admin.directory.user.securityβ user lifecycle (separate scope for suspend/revoke).gmail.modifyβ read, send, label management (subsumesgmail.send/gmail.readonly/gmail.metadata).gmail.settings.basicβ required to set per-pair send-as display name; NOT ingmail.modify.calendarβ full personal calendar (subsumescalendar.events).driveβ full My Drive (subsumesdrive.file; required because clients share existing docs in).contactsβ Specialist's address book.meetings.space.created+meetings.space.readonlyβ create Meet spaces and read any Meet conference (Specialist needs both to handle meetings clients invite them to;meetings.space.createdalone only covers spaces the SA itself created).
Excluded by design: https://mail.google.com/ (legacy super-scope, triggers restricted-scope OAuth verification), tasks (out of scope for v1), admin.directory.group (DLs-per-client not shipped), contacts.other.readonly (auto-collected contacts surface not needed).
Credential storage: service-account JSON key persisted encrypted via the existing integration_credentials envelope (KMS-wrapped, see #551). Env var name: GOOGLE_WORKSPACE_SA_CREDENTIALS_REF (a reference id; the actual JSON is looked up from integration_credentials at runtime, never injected as env). Rotation runbook: see Β§5.
3. Admin SDK quota headroomβ
Published Admin SDK limits (Directory API, as of 2026-05; verify against Google's quota page before merging):
| Operation | Per-project per-second | Per-project per-day | Per-user per-second |
|---|---|---|---|
users.insert | 5 | 1,500 (write quota) | 1 |
users.update | 5 | shared above | 1 |
users.delete / suspend | 5 | shared above | 1 |
Worst-case provisioning burst: 50 SpecialistΓClient pairs assigned in a single onboarding day (per #679 acceptance criterion).
Headroom calculation:
- 50
users.insertcalls at 5/s = ~10 seconds of API time, well within per-second budget. - 50 inserts vs. the published ~1,500/day write quota = ~3.3% utilization. We can provision β₯ ~1,500 / 50 β 30 days' worth of worst-case onboarding bursts before hitting the daily ceiling β i.e. headroom is daily-quota-bound, not burst-bound.
- At current scale, throttling is not a realistic concern. Reconsider if onboarding velocity exceeds ~300 new pairs/day.
Caveats:
- Numbers above are project-level; per-user quotas (service account acting as admin) match.
- Daily quotas reset on Pacific Time midnight.
- TODO once tenant exists: measure actual quotas in Cloud Console β Google's documented numbers have lagged the dashboard before.
Mitigation if we ever approach the ceiling:
- Token-bucket rate-limit in the provisioning hook (#680).
- Exponential backoff on
403 quotaExceeded. - Spread inserts across the day if a single onboarding event would queue >300 pairs.
4. Billingβ
| SKU | Per-user/month | Notes |
|---|---|---|
| Business Standard | $12 | Confirmed in #679 scope. 2TB pooled storage / user, Gmail + Calendar + Drive. Sufficient for our Specialist mailbox use case. |
| Business Plus (alt) | $18 | Adds Vault retention, advanced endpoint mgmt. Not justified for Specialist mailboxes; revisit if compliance requires Vault. |
Growth alerts:
- Workspace Admin console β Reports β set alert at
user_count > 1.2 Γ <last week's count>weekly. - Mirror in monitoring: daily cron compares
SELECT count(*) FROM org_specialist_assignments WHERE deleted_at IS NULLagainst current Workspace user count viadirectory.users.listβ alert on drift > 5%. - Cost model line item:
(active org_specialist_assignments) Γ $12/monthβ tracked in finance projection.
Cost projection (illustrative):
- 10 clients Γ ~5 Specialists each = 50 mailboxes = $600/month at Business Standard.
- 100 clients Γ 5 = 500 = $6,000/month. Still rounding noise vs. infra spend.
5. Incident runbook (workspace-side failures)β
| Incident | Detection | First response | Owner |
|---|---|---|---|
| Specialist account locked (Google sign-in) | Outbound send fails with 403 userLocked | Unlock via Admin console; if repeated, investigate brute-force / IP block. | Ops (Paul / Eusden) |
MX/DNS failure on h.work | Inbound email bounces; CloudflareβGmail webhook fails. | Verify MX records (mx.google.com.); re-add if drift. Run dig MX h.work. | DevOps |
| Service account credential leak | Audit log shows unexpected actor | Rotate SA key (Cloud Console β SA β keys β revoke + create new); update integration_credentials; redeploy. | Paul |
Admin SDK 403 quotaExceeded (sustained) | Provisioning hook logs | Back off provisioning; file ticket with Google for quota increase; manual user.insert via Admin console for blocked pair. | Ops |
| Whole-tenant suspension | All API calls 403 forbidden | Page Google Workspace support; check billing/payment status; comms to affected clients. | Paul + Finance |
| User suspended unexpectedly | directory.users.get returns suspended:true without matching org_specialist_assignments.deleted_at | Trigger reconciliation job (#684); investigate cause; unsuspend if assignment is still active. | Ops |
Escalation path: Workspace incidents β Paul β Eusden β Google Workspace support (priority based on SKU; Business Standard = standard support hours).
Credential rotation cadence: SA key rotated every 90 days, or immediately on any suspected leak. Calendar reminder owned by Paul.
Consequencesβ
Positive:
- Provisioning hook (#680) has a fixed quota target; no surprises at burst time.
- Single auth model (SA + DWD) avoids per-mailbox OAuth dance β onboarding SLA achievable.
- Per-environment tenants prevent dev provisioning bugs from touching prod billing/users.
- Cost is linear, predictable, and small compared to infra spend.
Negative / risks:
- DWD is a high-trust credential; compromise = tenant compromise. Mitigated by KMS, rotation, and audit logging.
- Daily Admin SDK write quota is a (very distant) ceiling; if HP grows to ~300 new pairs/day onboarding velocity, design must revisit batching.
- Three tenants Γ three domains = 3Γ the Workspace admin surface to maintain. Acceptable cost for isolation.
Acceptance checklistβ
- Workspace tenant provisioned for prod (
h.work). - Workspace tenant provisioned for staging (
h853.work). - Dev tenant (
h852.work) confirmed on same auth model. - Service-account credentials generated and persisted (encrypted) in
integration_credentials;GOOGLE_WORKSPACE_SA_CREDENTIALS_REFdocumented in.env.example. - DWD configured with the four scopes above for each tenant.
- Billing: Business Standard SKU confirmed, growth alert configured in Admin console.
- Quota headroom verified against Cloud Console dashboard (not just published docs).
- Incident runbook reviewed by Ops.
- This ADR moved from
DrafttoAccepted.
Open questionsβ
- Do we need Workspace Vault for compliance / data retention? (Drives SKU decision: Business Standard vs. Business Plus.)
- Should
h852.work(dev) share a billing account with prod for unified cost reporting, or stay financially isolated? - Quota-increase request to Google: pre-emptive, or wait until we approach the ceiling?
Referencesβ
- Parent epic: #678 β Workstream: Google Workspace lifecycle.
- Spike issue: #679.
- Consumer epic: #587 β Email channel migration to Gmail.
- Credential encryption: #551 β
integration_credentialsenvelope (closed). - Google docs: Admin SDK Directory API limits, Domain-wide delegation.