Divvent

For Divvent’s own team. It is generated from the operations document in our repository, which is the one to edit. If you use Divvent, the help centre is for you.

1. Onboarding a tenant, end to end

Divvent creates and owns each company's Cloudflare account (DESIGN.md §17, decision 77; Ant, 2026-09-28). The customer never sees Cloudflare, never adds a card, and never makes or sends a token — so every step below is an operator's, in Divvent's own Cloudflare organisation, and nothing is asked of the customer but their company's name and the owner's address (§1.4a). What they are told is that their application runs in the UK/EU region.

One command covers §1.1 to §1.4, whichever way it runs:

divvent tenant provision heatco --env staging

With the Tenant API switched on it does all of it; switched off — which it is, until Divvent has partner access — it creates nothing and prints the steps below, the last of which is the console's Register form.

§1.3a is one more, only for a company whose custom-domain edge zone will be registered — its own registrar credential and the Registration Agreement — and §1.7's Cloudflare for SaaS click at a company's first custom domain is the last: accepted by Ant only as the last resort (DESIGN.md §17, decision 45), until Cloudflare answers the question written there. Ant wants zero per-customer hand steps; these are what stands between here and that, and each says why.

1.1 Provision the company's Cloudflare account — BY HAND until partner access

Interim, by hand. In Divvent's own Cloudflare organisation, create an account named **divvent-<slug>** — divvent-heatco. The name is what the Tenant API path finds the account by on a retry and what divvent tenant close checks before it deletes one, so it is not decoration: use exactly that. It is Divvent's account, on Divvent's billing; no customer is ever a member of it.

Through the Tenant API, once switched on. divvent tenant provision does it with tenantApiProvisioner (packages/deployer/src/provisioning.ts), on the provisioning machine, with these in its environment — never as arguments:

VariableWhat
DIVVENT_TENANT_APIon to use the Tenant API; anything else is the interim, by hand
DIVVENT_TENANT_API_TOKENThe partner credential: a Tenant Admin's API token (never the Global API Key). It never reaches the control plane
DIVVENT_TENANT_UNITThe tenant unit to create accounts on (unit_tag from GET /user/tenants); only needed with more than one

In order, each verifying before it creates, so a re-run finishes a failed one rather than doubling it: find divvent-<slug> or POST /accounts it under the tenant; PUT /accounts/{id} with two-factor enforced; subscribe it to Workers Paid (POST /accounts/{id}/subscriptions, rate plan WORKERS_PAID) on the tenant's billing; revoke any previous divvent-deploy token and mint a new account-owned one (§1.3's four permissions, that account only, 90 days); prove the token can list Workers, D1 and R2; seal it through registerTenant. A run that created the account and then failed deletes it again and revokes the token it minted; a run that found an existing account keeps it for the retry.

What Ant applies for (partners@cloudflare.com — MSP partners; or agency@cloudflare.com — Agency partners; the Tenant API is for Channel and Alliance partners and needs a signed partner agreement):

  1. Tenant API access on Divvent's Cloudflare organisation, with a tenant unit to create accounts on.
  2. A scoped Tenant Admin API token for a service user, so the partner credential is a bearer token Divvent can rotate — not the Global API Key the docs' examples use (SECURITY.md §26).
  3. The Workers rate plan the agreement resells: WORKERS_PAID, or the PARTNERS_WORKERS_* plan it names instead (the adapter takes either).
  4. R2 on a child account by API. The Tenant API documents no subscription for R2, and an account without it stops a deploy at "Please enable R2 through the Cloudflare Dashboard". The adapter proves R2 before sealing and fails — cleaning up — rather than seal an undeployable account; ask how a tenant enables R2 (and D1's paid limits) on a child account.
  5. Per-account usage in the tenant's bill. divvent tenant costs reads each account's subscriptions; usage above a plan's inclusions is billed to the tenant, and the docs name no per-account breakdown of it.

Then register the partner token in the credential register the day it is issued, and set DIVVENT_TENANT_API=on on the provisioning machine.

1.2 Enable Workers Paid and R2 on it — BY HAND until partner access

In the interim, on the new account, in the dashboard: subscribe it to Workers Paid and enable R2 — R2 needs its terms accepted and a billing method present, which is Divvent's. No token permission substitutes for it.

Skip this and the deploy stops several minutes in, after the account exists, at Cloudflare's own "Please enable R2 through the Cloudflare Dashboard". That is why AccountProvider.ensureProducts refuses before anything is created rather than after, and why the Tenant API path proves R2 before it seals.

Data residency. Nothing to set on the account: Cloudflare has no account-wide region. Every database and bucket is created with the weur location hint (DEFAULT_LOCATION_HINT in packages/deployer/src/residency.ts), which is what a customer is told as "the UK/EU region".

1.3 Mint a scoped API token — BY HAND until partner access

In the interim: in divvent-<slug>, Manage Account → API Tokens, an account-owned token on that account only, expiring in 90 days, with four permissions, listed in .dev.vars.example with what each one is for (the Tenant API path mints exactly these, DEPLOY_TOKEN_PERMISSIONS):

PermissionWhy
Workers Scripts:WriteUpload the script, set secrets, claim the workers.dev subdomain
D1:WriteCreate the database and run migrations
Workers R2 Storage:WriteCreate the bucket and hold snapshots; set the media bucket's CORS (the application's own origins may PUT a presigned upload)
Account API Tokens:WriteMint the bucket-scoped media credential, and revoke it on rotation

The control plane's token needs more than a tenant's, and the full list is in .dev.vars.example with what each row is for. The one added most recently is Workers Containers:Write, which pushes the build image to registry.cloudflare.com and creates the container application; without it the deploy builds the image and is refused minting the registry credential with Authentication error, code 10000.

Every permission name in that file was read off a real token or the editor, and each carries the date it was read. Two have been wrong in this repository — once by guessing (Access: Device Posture, three deploys and a token roll) and once by over-correcting a guess (Access: Apps and Policies, which this repository asserted did not exist and which the staging token carries). A name that is nearly right reads as an answer, so the reader stops looking for one.

divvent control-plane token --env staging asks the account what the token can actually do, one read per permission. It is also on deploys.yml as a dispatch input, so it can be run from Actions without a checkout.

Nothing wider. In particular the deploy token is deliberately not the media credential: it can create and delete every bucket in the account, and the application only ever needs one.

No zone rows, custom domains included. Custom domains need zone access (§1.7), and the domains pass mints it for itself — a token on the one edge zone, for minutes, through Account API Tokens:Write — so the deploy token carries no zone permission at all. A token with any zone row is wider than needed: divvent tenant register says so, every domains pass says so, doctor lists it, and divvent control-plane token --account-env NAME --token-env NAME fails on it. Remove the zone rows and register it again.

1.3a The registrar credential, and the Registration Agreement — BY HAND, per company

Only a company whose edge zone will be registered (§1.7 path b) needs this; one whose own domain is already a zone in its account does not. Done in the same sitting as §1.3:

  1. Accept the Domain Registration Agreement in the company account (dashboard → Domain Registration → Registrations). Cloudflare's Registrar API requires it per account and offers no API for it.
  2. Mint a user API token scoped to this one account: My Profile → API Tokens → Create Custom Token, the Registrar permission (Account → Registrar, Edit — the editor may call it Domain Registration: read the name off it), Account Resources: Include → this company's account only. Nothing else — no zone rows. A user token because Cloudflare's account-owned tokens do not work with Registrar (its compatibility matrix, read 2026-09-27).
  3. Seal it: divvent domains registrar <company> --token-env <NAME_OF_VARIABLE_HOLDING_IT> --env staging. It is proved first (it reaches exactly this company's account, the registrar answers there, it reaches no zone), sealed into the credential register, and never printed. Rotating is the same command with the new token. A user token lives and dies with the Cloudflare user who minted it: when that operator leaves, mint and seal a new one for every company they set up (SECURITY.md §20).

There is no platform-wide registrar token: the one that reached every company account (DIVVENT_REGISTRAR_TOKEN) is gone, and nothing reads it.

Presigned upload needs no hand step and no new permission (2026-09-27, docs/proposals/13-ii-import-that-runs.md). Every deploy reconciles, on the tenant's own account: the bucket-scoped media credential as before, plus MEDIA_BUCKET beside it on the Worker (back-filled on a deployment made before, with no new token), and the media bucket's CORS — GET and PUT from the application's workers.dev URL and the hostnames its environment serves, content-type and if-none-match, nothing else — written only when it differs, a foreign rule replaced (the bucket is the deployer's own), and no rule at all where there is no origin (a rehearsal). The CORS endpoints need Workers R2 Storage:Write, which the token already holds. For production: nothing to do; the first deploy of this change sets both and logs media: cors set. If an operator ever sets CORS on a media bucket by hand in the dashboard, the next deploy replaces it.

1.4a Open the account — console

Console → Tenants → Open an account. A name, an optional slug and the owner's email address; the owner is the only role that can invite anybody else, which is why it asks for one rather than offering a choice.

The slug is previewed as the name is typed and does not change afterwards — it is in every URL and every Cloudflare resource a deploy composes. slugOf maps anything outside [a-z0-9] to a hyphen, so Müller & Co becomes m-ller-co; type a slug rather than accepting that one.

The invitation is emailed where the control plane knows its own portal URL and has a working transport. Where it does not, the screen shows the sign-up token once, for somebody to carry. It is shown once and nothing reads it back; revoking and reissuing in the portal is how a second usable one is produced.

divvent account create --name "Acme" --owner someone@example.com --env staging does the same thing from a terminal, and is still the bootstrap for a control plane with no operator inside it yet. It needs DIVVENT_OPERATOR set: every audit row it writes names that operator as the actor — never the owner's address, which is in no row of platform_audit (decision 18).

Run again with the same request it succeeds and writes nothing new: the account is verified, an owner who has joined is said and not invited again, and a pending invitation is left alone (--reissue, for an address on the internal domain, replaces it). --json says which in owner.state — joined, pending, reissued or invited. A different request under an existing slug is refused before anything is written — by this command and by the console's Open an account alike, which ask the same function (ownerOpening): an owner the company has never had (unless the address and everybody already in the company are on the internal domain, which is how the acceptance round opens a new owner each week), or somebody already in it under another role. "Had" means holds it now or has an owner's invitation that lapsed unused: an owner the company removed, or whose invitation was revoked, is not invited back.

--internal marks the company one of the platform's own (DESIGN.md §17, "The canary is ours"), and is refused unless it has an owner and everybody with a way in — members of any role, unexpired invitations — is on the internal domain.

The internal domain is the environment's: DIVVENT_INTERNAL_DOMAIN in infra/control-plane.json's vars — divvent.dev on staging and its previews, divvent.co.uk on production. An address is on it only exactly (someone@divvent.dev, not someone@x.divvent.dev or someone@divvent.dev.example). A deploy puts it on the console, and the CLI reads it from the manifest for the --env it is given; nothing sets it by hand. Where an environment names none, nothing is ours: --internal, --reissue, remove-member and a second owner on an existing company are all refused, and the refusal names the variable.

1.4 Register the tenant — console

Console → Tenants → Register: the company's slug, the id of divvent-<slug>, and the token from §1.3 — one credential per company, prod, which production and every trial beside it open. This is the interim provisioning form: behind Access, verified against Cloudflare first, the token cleared from the browser before the post leaves and never shown again. divvent tenant register <slug> --env <control plane> --account-env NAME --token-env NAME does the same from a terminal. The Tenant API path does it by itself. The token is sealed on the way in: the control plane holds three columns of ciphertext and cannot read one back, because Sealer has no open. Only the deployer's service identity holds a key that opens.

There is no view anywhere that shows a token's value. If somebody needs one they mint a new one and rotate.

1.5 First deploy

divvent deploy <modelDir> --env prod --tenant <tenant>

Into production, the one permanent environment; there is no slot to deploy to first (STAGE2.md §1). A first deploy has nothing to be held to, so it carries no --artifact.

What it does, in order: preflight (including AccountProvider, which fails with an instruction if 1.1–1.3 were skipped) → create the database and bucket → stage the Worker under another name → migrate → smoke → flip → watch for five minutes → roll back if the health check fails three times.

It is idempotent and resumable. A second run with nothing changed does every check and no writes; a run that died halfway resumes at the phase it reached.

The application's signing secret is its own (DESIGN.md §10). Through --via — which is how the runner deploys every tenant — the deploy opens the target's BETTER_AUTH_SECRET out of the control plane, generating and sealing one the first time, and puts it on the Worker; the platform's value in the runner's environment is never read for a tenant. Production and each trial have different ones. A redeploy puts the same value, so nobody is signed out.

There is no command that shows the value, and no screen. A person who thinks they need it wants a rotation.

A change tried in a trial reaches production as the same artifact (§5):

divvent deploy <modelDir> --env prod --tenant <tenant> --artifact <hash>

Passing --artifact is what makes "promote what was tested" checkable: the deploy refuses when its compile hashes to anything else, the hash goes in the deployment record, and production running a different one is a comparison somebody can make rather than a thing to hope about.

1.6 Let their people in

Two doors, and they are not the same door.

Into the portal — the customer's own colleagues, who change the model. An owner invites them as any of owner, admin, editor, finance (the finance approver) or viewer; an admin invites only an editor or a viewer — never a role holding a right the inviter does not, and never one that can itself invite (docs/proposals/34-portal-member-roles.md). The invitation carries the role, so accepting it makes somebody a member as that role; there is no second step where an operator assigns one. The People screen changes a role or takes somebody off later, under the same rules, and never the last owner — nor does divvent account remove-member.

Into their application — the people who use the thing we built. That is the application's own invite page, and which roles can be invited is derived from the model: a role whose auth: includes invite, sent by a role the policies let create that role's subject entity. Nothing here is an operator step either.

Somebody admitted with no role sees a page saying so, in both places (/awaiting-role in an application, the portal's own equivalent). That used to be a blank screen — every section omitted because no policy matched — and it was the most common "is it broken?" question there was.

A portal that invites somebody into a customer's running application does not exist, and is a decision rather than a gap: it needs either the deployer to deliver the invitation or the application to pull from the control plane, and both are a credential crossing the §10 wall. See docs/proposals/1a-invites.md.

1.7 Custom domains: the company's edge zone — automatic, but for one click at its first custom domain

A customer's own hostname (app.theircompany.com) is a Cloudflare for SaaS custom hostname in a zone in the company's Cloudflare account (Divvent's, §1.1) — its edge zone — and their CNAME points at edge.<zone> (docs/proposals/33-custom-domains.md §3). The edge zone is ours, never the customer's (Ant, 2026-09-28; proposal 33 §3.2): no customer-facing page, email or document names it, costs it or says it exists — its cost is inside the onboarding fee. Say nothing about it to a customer either; if one asks where their CNAME points, it points "at your application". The one place its name still reaches them is the CNAME record's value, which is what they must type (a Divvent-owned alias is proposed, not built). The domains pass provisions the edge zone itself (decided by Ant, 2026-09-27), the first time a company has a verified hostname to attach:

What the pass needs: the registrant (§9.0, once), a company account with a payment method (it has one already: §1.2 — and it is what pays for the registration, below), the company's deploy token (§1.3) — with no zone rows: the pass mints a token for the one edge zone by id, with the four permissions below, for at most 15 minutes, and revokes it — and, for path (b), the company's own registrar credential (§1.3a).

Minted for the edge zoneWhy
SSL and Certificates Writecustom hostnames and the fallback origin
Workers Routes Writea route per hostname to the application's Worker
DNS Writethe fallback origin's record, and the CNAMEs written in a customer's own zone
Zone Readthe zone's own reads

Finding the zone (and a customer's own) is a separate Zone Read token over the account's zones; making a registered name's zone a Zone Write one; each is minted, used and revoked the same way.

Re-register the token if it was rolled (divvent tenant register).

What an operator sees. Each pass prints what it did per company — edge acme.co.uk — the customer's own zone, registered northwind-edge.com, registering … the next pass asks again, refused … — <the registrar's reason> — and fails the run on a refusal, a company with no registrar credential (§1.3a), Cloudflare for SaaS not enabled on its zone, or a lapsed registration, so a red deploys.yml is where any of it shows. A refusal is also mailed once to the operator address and asked again a day later; a rate limit is simply asked again. Each registration's expiry is in the credential register as domain-registration:<zone>, with the usual 30/14/3/0-day reminders; auto-renew is on at Cloudflare. A registration that lapses is LAPSED on every pass, mailed to the operator, and nothing new is attached in it: renew it in the company account's dashboard (Domain Registration), and the next pass reads the new date.

Who pays for the edge domain (decided by Ant, 2026-09-28; DESIGN.md §17, decision 46). A registration — a few pounds a year, never more than EDGE_PRICE_CAP ($15) — is charged to Divvent's billing on the company's Cloudflare account (Divvent holds it, §1.1), and its yearly renewal the same way by auto-renew. It is part of the company's onboarding fee: the onboarding pricing covers it, so it is never a separate charge to the customer and there is no platform fund to top up. A company whose own domain is already an active zone in its account is registered nothing. The customer is to be shown the cost when the first domain is added — not built yet (§7); until it is, whoever quotes a company's onboarding says it.

One click Cloudflare keeps in the dashboard — BY HAND, once per company, at its first custom domain, and only as the last resort (decided by Ant, 2026-09-28; DESIGN.md §17, decision 45: automate it wherever an API exists, ask Cloudflare for one, and accept this click only until there is). Enabling Cloudflare for SaaS on a zone is the dashboard's SSL/TLS → Custom Hostnames → Enable (non-Enterprise: it asks for payment information). Researched 2026-09-27: Cloudflare's docs name no API for it — not creating the first custom hostname, not custom_hostnames/fallback_origin (both need it on; a zone without it answers 403, code 1404, "No quota has been allocated for this zone"), and not the zone subscription API, whose plan ids include no SaaS plan. So, until Cloudflare says otherwise: when a company's edge zone is registered (path b), or it has a hostname outside its own zone (path a), the pass says Cloudflare for SaaS is not enabled on <zone> … the one hand step and fails that run; the customer's page says attaching meanwhile. Then:

  1. Sign in to the Cloudflare dashboard as a member of the company's account whose role holds billing as well as its zones — the account's Super Administrator does; Cloudflare's Administrator role leaves billing out, and Enable may ask for payment. The zone is the one named in the failed run's line, in the company's account, not Divvent's own.
  2. That zone → SSL/TLS → Custom Hostnames → Enable. Where it asks for payment information, it is the card already on the account (§1.2).
  3. Nothing else: do not add a fallback origin or a hostname by hand — the pass writes both. The next pass (at most 30 minutes, or re-run deploys.yml) attaches, and the page goes waiting for the CNAME → live.

It is once per company: every later hostname in that zone needs nothing. Names inside a customer's own zone need none of this. When Cloudflare answers with an API, the pass calls it before the first custom hostname and this click goes from the runbook.

The question put to Cloudflare (support, or the account team), word for word:

If the answer is no, the click stays, as decided — once per company, at its first custom domain. The shared zone plus router (proposal 33 §3.1) is not the answer to it: it remains the fallback for registration proving impractical.

When a company leaves (proposal 33 §4.1 — the safe default; Ant may change it): completing its deletion (completeBillingTask) is refused the first time and starts releasing its edge zone. The next domains pass detaches every hostname and prints released <zone> — auto-renew off; it lapses <date> and is never transferred (a registered domain, turned off with the company's own registrar credential), or released <zone> — the customer's own domain, untouched but for N records the pass had written there. Complete the deletion again after that. Nothing to do at Cloudflare: the domain lapses on its own and nothing renews it; doctor lists released zones.

Its sending domain, BY HAND until the deletion does it (proposal 33 §4.1, §8). The deletion cascades the company's platform_sending_domain row and does not remove the domain at Resend, so it would stay in Divvent's Resend account, verified while the customer's DKIM record is published. Before completing the deletion the second time: find the company's sending domain on its audit trail in the console's Audit (sending-domain.add, the subject is the domain; none, and there is nothing to do), then in Resend's dashboard → Domains delete that domain. An owner who pressed Stop sending before leaving has already removed it (sending-domain.remove).

An override. divvent domains zone <company> --zone <name> --env staging still names an edge zone by hand, for a zone that already exists; it is never a step, and it is refused while a registration is in flight.

Sending domains need nothing per company. The portal's RESEND_ADMIN_KEY (a Resend full-access key, §9.0) creates each company's sending domain in Resend's EU region and mints its sending key restricted to it; unset, a customer can add and prove a sending domain and the page says the rest is not switched on.

Custom domains, every hand step and permission in one place. What the customer does and sees is proposal 33 §1; this is the operator's half.

WhatWhenWho, and with what
Deploy token with no zone rowseach company, at §1.3the operator minting it; tenant register, every pass and doctor say a wider one
Registration Agreement accepted, and a registrar user token on that one account, sealed with divvent domains registrareach company whose edge zone will be registered, at §1.3athe operator, as a Cloudflare user who is a member of the company's account; the token lives with that user, so it is rotated when they leave
Cloudflare for SaaS Enable on the edge zone — BY HANDonce per company, at its first custom domain outside its own zone, when a pass says soa member of the company's account whose role holds billing (above)
DIVVENT_REGISTRANT_CONTACTonce per environment (§9.0)whoever holds the GitHub environment
RESEND_ADMIN_KEYonce per environment (§9.0)whoever holds the Resend account and the GitHub environment
A lapsed registration renewedwhen a pass prints LAPSEDin the company account's dashboard, Domain Registration
A refused registration (a declined card, no name under the cap)when a pass prints refused and the operator is mailedfix the cause at Cloudflare; the pass asks again after 24 hours
An edge zone named by hand (divvent domains zone)never a step; an overridean operator with the control plane's credentials
A leaving company's sending domain deleted at Resend — BY HAND until builtbefore completing its deletionwhoever holds the Resend account

Nothing here reads a tenant's application data, and no step needs the company's deploy token to reach a zone: the pass mints and revokes its own. The domains pass holds the same secret on its own step (deploys.yml, "Reconcile customer domains", and no other runner step) and, every pass (proposal 33 §5.1, §5.2):

Sending work waiting with RESEND_ADMIN_KEY unset on the step is said (waiting sending domains — RESEND_ADMIN_KEY is not set on this step) and fails the run.

---

1.8 What each company's account costs, and the plan price

Divvent pays Cloudflare, and a company's Divvent plan price covers it. Once a month:

divvent tenant costs --env staging --token-env DIVVENT_TENANT_API_TOKEN

reads each registered company's account subscriptions — with the partner token, or in the interim a token with Billing Read on Divvent's accounts, named by variable — and records the month per company (platform_cloudflare_cost). Console → Billing → Cloudflare, per company shows the latest month beside each company's plan, most expensive first. Billing numbers only: the control plane reads nothing inside an account. Usage above a plan's inclusions is billed to the tenant as a whole and is not in these numbers yet (§1.1, item 5).

Decided by Ant (accepted recommendation), 2026-09-28 (DESIGN.md §17, decision 77) — the shape below; the prices themselves are still Ant's to set (decision 30 left every paid price a placeholder, PLAN_CATALOGUE in packages/control/src/plans.ts):

1.9 When a company leaves — the account after the export window

A company that stops paying is paused, read-only, and can export until its subscription's deleteAfter. Only after that:

divvent tenant close heatco --env staging

refuses a company that has not churned, refuses before the window has passed, and refuses an account not named divvent-<slug>. Through the Tenant API it deletes the account; in the interim it prints the steps (delete it in Divvent's Cloudflare organisation). Deleting the account deletes every Worker, database and bucket in it, and cannot be undone.