Purpose. Define how a hosted Telemachus tenant comes into existence: provision an isolated instance and seed exactly one account owner — with no other users in the system — driven by an external event (a trial signup, a paid subscription, a launched VM). Self-hosted deployments keep their interactive first-run bootstrap; this doc is the hosted path and the piece a control plane needs to automate it.
This is the "hosted multitenant offering" the tenancy decision defers to: cross-legal-entity multitenancy on a shared instance is a non-goal, so the hosted answer is one isolated instance per tenant. Seeding a lone owner is therefore not a special mode — it is simply what a freshly provisioned instance is.
| IS | Per-tenant instance provisioning, an idempotent seed-one-owner step, and a secure first-login (magic-link activation), all triggerable by an external event. |
| IS NOT | Shared-database multitenancy (explicit non-goal); a billing system; an email/SMS service; VM/container orchestration. Those live in the control plane (below), not in the OSS platform. |
provision_id → instance URL), and sends the activation
email. Keeping billing/orchestration out of the OSS repo protects the
clean-MIT, no-open-core posture.refimpl/racketmaximus): exposes the provisioning +
activation hooks and the operator control
surface the control plane calls. Almost everything else it
needs already exists (operator tier, api_tokens, quotas,
status fields, audit_log).Self-hosted collapses these (RBAC-5: the first owner bootstraps as
operator). Hosted keeps them apart — the platform
already made operator a distinct tier
(is_operator, instance:* operator-only)
precisely to allow this.
| Tier | Held by | As | May |
|---|---|---|---|
Provider (instance:*) |
You, the provider | the per-instance provision token (a secret, not a user) | provision, suspend/resume, quota/plan changes |
| Owner | The customer | the one seeded user | run everything, manage their team, invite members — not raise their own quota or unsuspend |
As built (ONB‑2): provider actions authenticate with
the per-instance provision token rather than any user
or api_token principal — so the seeded instance contains
exactly one user (the customer owner), satisfying "no
other users in the system" literally. The operator RBAC
tier still exists for self-hosted deployments. See rbac-and-teams.md.
Generic types (text, int,
timestamp, json) per the house tenet — no
SQLite-only constructs, so the SQLite→Postgres move stays a
db-kit swap. Most of this reuses existing tables.
provisioning — idempotency + audit of
each seed (new):
| field | type | notes |
|---|---|---|
id |
text (uuid) | pk |
provision_id |
text unique | external id: Stripe subscription_id, VM id, signup
id |
source |
text | signup | subscription |
vm |
plan |
text | maps to a quota preset |
status |
text | seeded | activated |
suspended | deprovisioned |
owner_user_id |
text | the seeded owner |
created_at / updated_at |
timestamp |
users.status — extend the existing
enum: invited → active →
suspended. A seeded owner starts invited with
no usable password hash.
activation_tokens (new, mirrors
api_tokens):
id, user_id, token_hash, prefix, expires_at, used_at, created_at
— single-use, expiring, hashed at rest.
Provider auth — the per-instance provision
token (TELEMACHUS_PROVISION_TOKEN), sent as
X-Provision-Token. No operator user or service token is
created, so the tenant holds exactly one user. (The
api_tokens + instance:* service-token
mechanism remains available for self-hosted operators.)
Gated by a mode flag:
TELEMACHUS_MODE=saas disables interactive
/api/bootstrap (closing its open first-run race)
and enables the endpoints below.
| endpoint | auth | does |
|---|---|---|
POST /api/provision |
X-Provision-Token (per-instance secret) |
idempotent on provision_id: create
team + owner (invited, no password), apply plan quotas,
mint an activation token, optionally mint the operator service token;
returns
{ activation_url, owner_user_id, provision_id } |
GET /api/activate?token=… |
the token | validate + show the set-credentials page |
POST /api/activate |
the token | set password (+ optional TOTP) → owner active, token
used; returns a session |
POST /api/instance/suspend | /resume |
X-Provision-Token |
flip tenant status; suspended = writes
402, reads OK |
POST /api/quota (exists) |
instance:manage |
plan-driven limit changes (a provision-token path for hosted quota changes is a small lifecycle follow-up) |
Boot-env seeding (for launched VMs). Instead of an
inbound call, a VM's cloud-init can set
TELEMACHUS_SEED_OWNER_EMAIL, …_NAME,
…_ORG, TELEMACHUS_SEED_PLAN, and
TELEMACHUS_PROVISION_TOKEN. On first boot with zero
users, the instance self-seeds and emits the activation token
to a provisioning sink (stdout line / file / webhook)
for the control plane to relay. Same idempotent seed code path as
/api/provision.
Event (signup / Stripe webhook / VM launch)
│
▼
Control plane ── verify + dedupe on provision_id
│ provision instance; inject env:
│ TELEMACHUS_MODE=saas, PROVISION_TOKEN, plan
▼
POST /api/provision { owner_email, owner_name, org, plan, provision_id }
│
▼
Instance (idempotent): if no users →
create team + owner(status=invited, no password)
apply plan quotas ; mint activation token (single-use, 72h)
→ { activation_url }
│
▼
Control plane emails owner the magic link
│
▼
Owner opens /activate?token=… → sets password (+2FA) → status=active → signed in
(only user in the instance; invites teammates via the existing members flow)
| Trigger | Control-plane action | provision_id |
|---|---|---|
| Trial signup (web form) | provision, seed owner, trial quotas, email link | signup id |
Paid subscription (Stripe
checkout.session.completed) |
verify signature → provision → seed owner (email from Stripe customer) → email link | subscription_id |
| Launched VM (cloud-init) | VM self-seeds from TELEMACHUS_SEED_*; orchestrator
records URL + relays the activation token |
VM/instance id |
provision_id is
unique; a retried webhook returns the existing
activation_url (or 200 if already
active) — never a second owner./api/provision again
while the owner is still invited re-issues a fresh
activation token (old one invalidated).subscription.updated → POST /api/quota;
past_due → suspend after a grace window;
canceled → suspend, then after a
retention window the control plane
deprovisions (destroys the instance/VM) — with a
data-export hook first.saas mode — the open first-run race is gone.402
+ read-only, data retained); export allowed; deprovision is a separate,
explicit step.audit_log.plan maps to a preset applied
via set-limit! at provision; lifecycle events adjust it.
(quotas.md)audit_log.| OSS platform (this doc) | Control plane (commercial, not OSS) |
|---|---|
TELEMACHUS_MODE=saas; guarded
/api/provision; /api/activate; suspend/resume;
operator service tokens; boot-env seeding |
Stripe/billing; email sender; VM/container orchestration; tenant
registry (provision_id → URL); the signup web funnel |
🔑 = gates the build.
/api/provision and boot-env seeding
(recommended) — endpoint for signup/Stripe, env for VM
launch.402
+ read-only, data retained (recommended) vs. hard
403. Keep export available.Built (slice 19). TELEMACHUS_MODE=saas,
POST /api/provision, POST /api/activate,
POST /api/instance/{suspend,resume}, boot-env seeding, the
/activate SPA view, and the read-only suspend gate are
implemented in refimpl/racketmaximus
(domain/saas/onboarding.rkt, migration 0006,
test/saas-tests.rkt). Verified end-to-end: provision →
single invited owner → magic-link activation → login; suspend blocks
writes (402) but allows reads; resume restores.
One refinement landed vs. this proposal — ONB‑2: provider actions authenticate with the per-instance provision token rather than an operator service token, so the seeded instance holds exactly one user (the owner). The operator tier still exists for self-hosted. The billing/email/orchestration control plane remains out of the OSS repo. Retention/deprovision (ONB‑8) is a control-plane concern. Recorded in decisions.md (ONB‑1…8).