Harbor Plan
Doc v0.1InternalApr 2026
Internal planning document · Managed OpenBerth

The plan, visible.

Every decision we've made about what to build, how to build it, what to defer, and why — laid out end to end. The first three phases make up our MVP: enough of the core service to start selling it. Everything after that waits for traction.

At a glance
Managed OpenBerth
MVP scopePhases 0–3
AudienceNon-technical
InfraCloudflare
DataD1 + Drizzle
AuthBetter Auth
BillingStripe
EmailResend
Tables7
Workers3
I

From foundation to growth, sequenced.

Phase 0
Foundation
Monorepo, Workers, D1, auth, CI. Nothing user-facing.
Phase 1
Core product
Workspaces, OpenBerth proxy, trial lifecycle.
Phase 2
Billing
Stripe, plan changes, suspension, termination.
Phase 3
Email
Transactional notifications via Resend + Queues.
Phase 4
Invitations
Multi-account workspaces. Deferred.
Phase 5
Deletion + transfer
Account deletion cascade, billing ownership transfer.
Phase 6
CLI polish
Dedicated onboarding for native OpenBerth access.
Phase 7+
Scale & growth
Hardening, admin tools, public API, enterprise.
0
Foundation
MVP

Rails, not product.

The plumbing that everything else sits on. No one uses anything from Phase 0 directly, but if we skip it we pay for it ten times later.

A
pnpm monorepo — web, reconciler, infra-consumer, shared
B
Next.js on OpenNext, Cloudflare Pages
C
D1 + Drizzle + migrations
D
Better Auth + Google provider
E
Secrets Store + envelope encryption helpers
F
GitHub Actions CI, preview deploys
G
Structured logs, trace IDs, metrics baseline
H
Wrangler local dev, Miniflare for all primitives
1
Core product
MVP

The actual product, minus money.

What a single customer can do end to end with their workspace. Sign up free, create workspaces, use OpenBerth through the dashboard, manage credentials, see clear status — all without a credit card.

Accounts
Google sign-in, profile, 14-day trial on signup
Workspaces
Create (instant), list, view, rename, delete
Regions
EU & US, plan-gated selection
Lifecycle
State machine, queue events, DO coordination
Authz
Permissions, roles, invariants module
Plans
Versioned catalog with entitlements
Proxy
Deployments, sandboxes, users, secrets, settings
Credentials
Admin key encrypted, per-request decrypt
Trial
Countdown, expiry cron, cascade to suspension
Copy
Non-technical language throughout
2
Billing
MVP

Where the money comes in.

Stripe does the heavy lifting. Checkout for upgrades, Customer Portal for everything else. Webhooks drive state changes via an idempotency table. Plan changes unify with trial expiry under one suspension flow.

Checkout
Stripe-hosted upgrade flow
Portal
Cancel, resume, update card, invoices
Webhooks
Signature verify, idempotency, async handlers
Downgrade
Choose which workspaces to keep modal
Suspension
Unified trial / downgrade / payment-failed flow
Termination
14-day grace, then destroy
3
Email
MVP

Notifications that matter.

Everything lifecycle-adjacent that a customer needs to hear about. Resend for delivery, React Email for templates, Queues for async dispatch. Idempotency on sends.

Welcome
On sign-up, trial introduction
Trial
T-3 days, expired, workspaces at risk
Billing
Payment success, failure, card update nudge
Workspace
Suspended, resumed, termination warning
4
Invitations
Later

When workspaces need teammates.

Multi-account workspaces, invite-by-email flow, role management UI. Schema already supports it via memberships — this phase exposes the UI.

Invitations
Token-based, email-delivered, time-limited
People page
Driven by memberships + accounts
Roles
Change role, remove, leave workspace
Invariants
Already implemented — UI just exposes them
5
Deletion & transfer
Later

Graceful offboarding.

Full account deletion cascade with a 14-day grace period. Billing ownership transfer between owner-role members, subject to receiving account's plan capacity.

Deletion
Request, grace period, cascade, undo link
Transfer
Billing owner change with email confirmation
Capacity check
Receiving account must have slot available
Workspace delete
Confirmation UX, final warnings
6
CLI polish
Later

Native OpenBerth access, first-class.

The primitive works from Phase 1 — a customer can create an OpenBerth user and paste the key into the open-source CLI. This phase adds polish, guided setup, MCP discovery.

Setup UX
Guided install & configure for the OSS CLI
Templates
Named users for common use cases
MCP
Surface Claude / AI-tool connection path
7+
Scale & growth
Later

When there are enough customers.

The hardening work that stops becoming optional past a few thousand users, plus the growth features driven by customer demand.

Observability
Billing / lifecycle / authz dashboards
Rate limits
Per-account, per-proxy, public endpoints
Admin tools
Staff-only panel, impersonation, state-unstick
Public API
Personal access tokens, scoped, rate-limited
Enterprise
SAML/OIDC, audit log, custom roles
Usage billing
Meter OpenBerth usage for overages
II

What we have when we start selling.

FeatureP1P2P3
Account & auth
Sign in with Google
Account profile
Sign out
Trial & paid plans
14-day free trial, no card
Trial countdown in dashboard
Pricing page
Upgrade via Stripe Checkout
Customer Portal
Plan downgrade with choose-to-keep UX
Workspace lifecycle
Create, list, rename, delete workspace
Region picker (EU, US) plan-gated
Four-state status UX
Live status during transitions
Suspend on trial / payment / downgrade
Grace period & automatic termination
OpenBerth dashboard
Deployments: list, create, update, delete, logs
Sandboxes: full CRUD + promote
OpenBerth users: create, rotate, delete
Secrets: per-user + global
Workspace settings
CLI access
OSS OpenBerth CLI compatibility
Create named users with copyable keys
Email notifications
Welcome on sign-up
Trial ending / ended
Payment events
Workspace suspended / resumed / at risk
● shipped in this phase   ○ not yet    A solo customer at end of Phase 3 can sign up, try free, upgrade, run a workspace in EU or US, use every OpenBerth feature, and get every email they should.
III

Three workers, one database, one queue.

CLOUDFLARE web NEXT.JS · OPENNEXT · UI (pages) · Server Actions · Auth callbacks · Stripe webhook reconciler SCHEDULED WORKER · 5-min safety net · Trial expiry cron · Termination cron infra-consumer QUEUE CONSUMER · State-change events · Idempotency check · Dispatch to DO WorkspaceCoordinator DURABLE OBJECT · ONE PER WORKSPACE Serializes state transitions, validates lifecycle, writes DB D1 · SQL KV R2 Queue · infra events Secrets Store Infra service OUTSIDE OUR WORKERS · BUILT BY INFRA TEAM · provision() → {endpoint, admin key, ids} · suspend / resume / destroy provision (sync) events →
Workers & storage
Stateful / dark paths
Cross-boundary calls
Layered code inside web

Four strict layers, each depending only on the ones below. Presentation is thin. Application orchestrates. Domain is pure logic. Infrastructure is adapters.

Presentation  →  Server Actions, Routes
Application  →  use cases, orchestration
Domain  →  authorize, plans, lifecycle
Infrastructure  →  D1, Stripe, OpenBerth, crypto
Why Durable Objects

One DO instance per workspace. All state transitions for that workspace funnel through it. Concurrent refreshes, queue events, and cron-driven updates can't race because the DO serializes them by construction. This is the concurrency primitive the architecture depends on to scale past a few thousand customers without adding locks or careful transaction choreography.

IV

Every entity, named precisely.

accounts
Platform identity
idTEXT PK
emailTEXT UNIQUE
name, imageTEXT
plan_id→ PLAN_CATALOG
trial_started_atTIMESTAMP
trial_ends_atTIMESTAMP
stripe_customer_idTEXT · P2
stripe_subscription_idTEXT · P2
deletion_requested_atTIMESTAMP · P5
created_at, updated_atTIMESTAMP
workspaces
Resource container
idTEXT PK
slugTEXT UNIQUE
nameTEXT
created_by_account_id→ accounts
billing_owner_account_id→ accounts
lifecycle_stateENUM
suspension_reasonENUM nullable
suspension_grace_ends_atTIMESTAMP
created_at, deleted_atTIMESTAMP
memberships
Account ↔ workspace
account_id→ accounts
workspace_id→ workspaces
roleowner|admin|member
joined_atTIMESTAMP
P1: always one membership per workspace. Structure ready for P4.
workspace_instances
Infra lifecycle mirror
workspace_id→ workspaces
infra_resource_idTEXT
regioneu-west|us-east
endpoint_urlTEXT
desired_stateENUM
workspace_stateENUM
infra_stateTEXT raw
last_reconciled_atTIMESTAMP
last_errorTEXT nullable
workspace_credentials
Encrypted admin key
workspace_id→ workspaces
admin_key_ciphertextBLOB
admin_key_nonceBLOB
key_versionINTEGER
admin_openberth_user_idTEXT
rotated_at, created_atTIMESTAMP
AES-GCM envelope encryption. Master key in Secrets Store. Decrypted per-request, never cached.
stripe_webhook_events
Idempotency · P2
event_idTEXT PK (stripe)
event_typeTEXT
received_atTIMESTAMP
processed_atTIMESTAMP null
infra_events
Idempotency · queue
event_idTEXT PK (ULID)
workspace_id→ workspaces
received_atTIMESTAMP
processed_atTIMESTAMP null
V

Entitlements, immutable once shipped.

Plans live in plans/catalog.ts, versioned with the app. Each plan ID includes a suffix (@1, @2). Once a version ships, it's immutable — grandfathered customers stay on it forever unless explicitly migrated. To change anything, create a new version.

trial@1 · active
Trial
Default on signup. No card required.
  • maxWorkspaces1
  • regionsEU, US
  • deploymentsPerWorkspace3
  • customDomainsfalse
  • prioritySupportfalse
  • trialDays14
  • stripePriceId
hobby@1 · active
Hobby
Personal projects. EU only.
  • maxWorkspaces1
  • regionsEU
  • deploymentsPerWorkspace10
  • customDomainsfalse
  • prioritySupportfalse
  • apiRateLimit60/min
  • stripePriceIdprice_hobby_01
pro@1 · active
Pro
Multiple projects. EU & US.
  • maxWorkspaces5
  • regionsEU, US
  • deploymentsPerWorkspaceunlimited
  • customDomainstrue
  • prioritySupporttrue
  • apiRateLimit600/min
  • stripePriceIdprice_pro_01
STATUS · ACTIVE

Shown on pricing page. New signups can pick it. Billed normally.

STATUS · DEPRECATED

Hidden from pricing. Existing customers keep their entitlements until explicit migration.

STATUS · RETIRED

Force-migrate or downgrade at renewal. Last resort — costs customer trust.

VI

Internal complexity, external simplicity.

The workspace state machine has eight internal states, but users only see four labels. A declarative mapping table translates raw infra states into our vocabulary. Unmapped infra states fall through to unknown — never silently broken.

What users see
pending → provisioning → ready
Ready
Workspace is operating normally.
provisioning
Setting up
Transient. Usually under 2 seconds with synchronous infra.
suspended
Paused
Trial expired, payment failed, or plan downgrade. Reason surfaced.
failed · unknown
Having trouble
Infra reported failure or went silent. Email sent.
State translation map · INFRA_STATE_MAP
// server/domain/lifecycle/state-map.ts
queued         →  provisioning
allocating     →  provisioning
booting        →  provisioning
running        →  ready
draining       →  suspended
stopped        →  suspended
crashed        →  failed
deleting       →  deprovisioning
deleted        →  deleted   · terminal
[anything else] →  unknown   · logged + alerted
Adding a new infra state = adding one line. Never a rewrite.
VII

Nine rules we check new decisions against.

01

Separate intent from reality.

Every stateful entity has a desired state and an observed state. Reconcilers close the gap. Never let a user action write the observed column — only reconciliation does.

02

One writer per resource.

Every state transition for a workspace flows through its Durable Object. No concurrent paths. No locks, no coordination bugs, no partial writes.

03

External truth, stable representation.

Infra states, Stripe events, OpenBerth errors — translate them all into our vocabulary. Their shapes can change. Ours doesn't.

04

Authorize in one place.

Every Server Action, use case, and route handler calls the same authorize(account, permission, resource). Plan entitlements and invariants live there — nowhere else.

05

Plans are code.

Versioned catalog in TypeScript. Immutable. Reviewed in PRs. Never edited in place. Grandfathering is not a feature — it's the default behavior.

06

Secrets never leave their layer.

Admin keys decrypted in-request only. No caching, no logging, no return-to-client. Master key from Secrets Store. Ciphertext in D1.

07

Non-technical user first.

If a label uses jargon, it's wrong. If an error says provisioning, it's wrong. Engineering vocabulary stays out of the UI and out of emails.

08

Future-proof the data, not the code.

Add schema capacity early because migrations are expensive. Don't add abstractions until you've hit the problem twice. Refactors are cheap; wrong abstractions aren't.

09

Defer until forced.

Outbox, audit log, plugin system, public API, custom roles — all considered, all deferred. They earn their place with specific pressure, not with speculative nice-to-have.

VIII

Things we've chosen not to build yet.

Every item here was considered during design and intentionally left out of the MVP. Each has a clear path back in when it becomes necessary — specific pressure, not speculative nice-to-have.

Outbox pattern
Not needed until billing goes live with real money and cross-system consistency pressure. Idempotency tables are sufficient for now.
Audit log
Structured logs are enough for MVP. Formal audit log becomes a feature when enterprise customers ask.
Public API & personal access tokens
No external consumer yet. Customers use the OpenBerth CLI directly for programmatic access.
Invitations & multi-account workspaces
Solo-user workspaces are the MVP. Schema supports it (memberships table) but the UI stays hidden until Phase 4.
Custom per-workspace roles
Hardcoded owner / admin / member covers 99% of B2B SaaS. Enterprise feature only.
Resource-level permissions
Workspace-level authorization is enough. Add a fourth parameter to authorize() when needed.
Plugin / feature-flag system
Plan catalog with feature booleans covers 95% of what people reach for feature flags for, at our scale.
D1 → Postgres migration
D1 fits our scale for a while. Repository layer is structured so the swap is mechanical when we need it.
Admin panel for staff
Wrangler + SQL covers MVP support. Real internal tool lands in Phase 7.
IX

Unresolved, but not blocking.

Provisioning failure recovery

When infra.provision() succeeds but our DB write fails, we have an orphan. Reconciliation job with idempotency keys is the plan — exact contract with infra TBD.

User-deletion blockers from OpenBerth

Does the error enumerate which resources block deletion? If not, showing 3 deployments, 1 sandbox requires extra lookups.

Sandbox listing endpoint

Is there a server-wide sandbox list API, or only deployment-scoped? Affects the view this OpenBerth user's resources UX.

Global secrets on user deletion

Do creator-owned global secrets block deletion? How is that surfaced to the account?

Workspace deletion grace period

Immediate hard delete, or recoverable with a grace window? Pending decision — stronger case for grace at this scale.

Expired-trial UX

Hard suspension (current plan) or read-only existing workspaces? Hard suspension creates upgrade pressure; read-only is kinder.