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.

6e. The command signing key: one per control plane, and rotating it

DIVVENT_COMMAND_SIGNING_KEY signs the account owner's reset of a member's second factor and their erasure request (docs/proposals/25-customer-command-channel.md, DESIGN.md §17 decision 7), and every tenant's telemetry credential. The deploys pass renews those credentials with it (divvent telemetry-renew, decision 15): set it on the deploys workflow's Renew telemetry credentials step and on no other — a step in a job of its own (renew), on a runner no tenant build has touched, because run builds every tenant it deploys and a build could rewrite what a later step on that VM runs — and every credential the scheduler asked for at sixty days is minted again and put on its Worker as one secret, with no redeploy. Unset, that step says so and claims nothing — and a target nobody redeploys stops reporting at ninety days, which its Health screen warns about from eighty. The portal holds the private half. Tenants hold only the public half (DESIGN.md §17, decision 23): deploy --via reads it from DIVVENT_COMMAND_PUBLIC_KEY — a GitHub environment variable, not a secret — and puts it on every tenant it deploys, as DIVVENT_COMMAND_KEYS, beside DIVVENT_COMMAND_AUDIENCE and DIVVENT_COMMAND_ISSUER. It never derives the set from the private key, so a step that deploys tenants — and runs each tenant's own build — holds nothing that signs. And it gives a tenant the channel only where the portal agrees, exactly: the deploy reads the set the control plane's own portal publishes at https://<portal>/.well-known/divvent-command-keys (the portal's address from infra/control-plane.json, https only) and compares it key for key with the variable. Anything else — a key more, another key, a portal with no key, or a portal it cannot reach — is no command channel: … in the deploy's log and a tenant with no channel; the deploy itself goes on. The variable is a value anybody who can edit the repository's variables can set, so it is checked against the one thing that cannot be edited that way: what the portal signs with. It is not provisioned yet (proposal 25, "Not yet on staging"); these are the rules for the day it is. A tenant deployed before the variable was set, or while it disagreed with the portal, gets the channel at its next deploy through the runner — divvent tenant redeploy raises one for a target nothing else would redeploy (Redeploying a target as it runs, below).

Where it goes. It is the portal Worker's own secret in infra/control-plane.json (workers[portal].secrets), so the control-plane deploy puts it on the portal and on no other Worker, and a deploy that supplies none leaves the portal as it is. DIVVENT_COMMAND_SIGNING_KEY_PREVIOUS is the portal's optional own secret (optionalSecrets): never demanded, uploaded when set, and kept on the portal — never pruned — until somebody deletes it. An empty value is not set. To provision an environment:

  1. Generate a key for that environment alone (base64 PKCS#8 over P-256).
  2. gh secret set DIVVENT_COMMAND_SIGNING_KEY --env staging (or --env production), pasting it at the prompt — never as an argument.
  3. Ant, once per environment: publish its public half. With the same key in this shell's DIVVENT_COMMAND_SIGNING_KEY (PowerShell: $env:DIVVENT_COMMAND_SIGNING_KEY = Read-Host -MaskInput; never an argument), run
    divvent secrets command-public-key | gh variable set DIVVENT_COMMAND_PUBLIC_KEY --env staging

    (or --env production). It prints the public key set and nothing of the private key — one line of JSON, which gh variable set takes on stdin. Then clear the shell's copy (Remove-Item Env:DIVVENT_COMMAND_SIGNING_KEY, or close it). The value is public: it is what every tenant already holds.

  4. Run that environment's deploy. staging.yml's Deploy the control plane step and production.yml's Promote that artifact step carry both key names from the environment's secrets, and no other step does; they carry the public variable too, and the deploy refuses a variable that is not exactly the public half of the portal's key — before anything is uploaded. Exactly: the current key and nothing else, not a key more (one every tenant would honour) and not the key before a rotation (the portal never signs a command with it). It refuses a variable set where the portal is given no key, too. With the key set and no variable yet, the deploy says so and goes on, and tenants deploy with no channel. And after the upload it reads the portal back: it asks the portal's own address for /.well-known/divvent-command-keys, for up to thirty seconds, and fails the run unless the set published there is exactly the public half of the key it was given — naming both sets by fingerprint and kid, never a key. The check before the upload compares two values in the runner, and on 2026-09-26 both were the new key while the portal went on publishing the old one; this is the check made of the portal itself. The tenant steps — deploys.yml's Do them, staging.yml's Deploy the test tenants, acceptance.yml's round — carry DIVVENT_COMMAND_PUBLIC_KEY from the environment's variables and neither key (workflow-stages.test.ts holds each). production.yml has no tenant step yet; whichever first deploys production's tenants carries the variable and never the key. A preview gets none of the three: it runs branch code in the staging environment, and staging's key in it would be the sharing this section forbids, so a preview's portal holds no key and its owner reset refuses.

Each control plane has its own key. Never copy one environment's into another's GitHub environment. refresh.yml copies production's control-plane database into staging, so staging holds production's application ids and tenant URLs — the very audiences production's tenants answer to. With one key between them, a command staging signed would verify in a production tenant. Generate it separately for each environment — the OIDC key's shape, base64 PKCS#8 over P-256; divvent secrets init does not make this one yet — and set it on that environment alone. The iss claim is the second line: the deployer tells each portal its own name (DIVVENT_COMMAND_ISSUER, the plane's database name, divvent-staging or divvent-prod), the runner tells each tenant the name of the plane it was deployed through, and a tenant refuses a command any other plane issued. It holds if the first line is broken by hand; it is not a reason to break it.

A malformed key does not take anything down. The portal serves and says "this control plane holds no command signing key"; a --via deploy whose DIVVENT_COMMAND_PUBLIC_KEY is unset or unreadable prints no command channel: DIVVENT_COMMAND_PUBLIC_KEY is … and deploys without the channel. Neither prints the value.

Rotating it, in this order:

  1. The environment. Set DIVVENT_COMMAND_SIGNING_KEY_PREVIOUS to the current key and DIVVENT_COMMAND_SIGNING_KEY to the new one, and DIVVENT_COMMAND_PUBLIC_KEY to the new key's public half alone (step 3 above, with the new key — never both keys).
  2. The control plane. Run its deploy, so the portal signs with the new key, still verifies telemetry credentials signed with the old one, and publishes the new set at /.well-known/divvent-command-keys. The deploy writes both keys to the portal on every run it is given them — replacing what the portal held — and fails unless the portal then publishes the new key's set (step 4 above). Before 2026-09-26 it put a secret only where the Worker had none, so this step went green and left the portal signing with the old key; a portal deployed before then that still publishes the old set is fixed by running the deploy once more.
  3. Redeploy the fleet. Every deployed tenant still holds the old public set until the runner deploys it again: a tenant nobody redeploys keeps honouring the retired key for as long as it runs. Nothing redeploys them for a key rotation. The fleet update rebuilds only a target whose engine or Runtime release is behind, so do the rest by hand (Redeploying a target as it runs, below), and let the doctor say which targets are left:
    divvent doctor --env <environment>

    The control plane's command keys row is the fingerprint of the set the portal publishes at /.well-known/divvent-command-keys: 16 hex characters of SHA-256 over the canonical set, or none. Each deployed target's command keys row is the fingerprint its own /_divvent/health reports, against the portal's. mismatch — holds … is a target still on the old set, and none — no command channel one deployed while the variable was unset or disagreed with the portal; the doctor names the command that redeploys each — divvent tenant redeploy <slug> --env <environment> --reason "…" for a target on the pins, its fleet update for one behind them. unknown (engine < 0.4.0) is a target built before tenants reported a fingerprint; it is not a failure, and it goes once the fleet is on 0.4.0. Run the doctor again until no target says mismatch. Under --require, as staging.yml runs it, a mismatch fails the run, and so does a portal whose set cannot be read. Between step 2 and a target's redeploy, that target's channel refuses. An owner is told the application refused the command, which is the honest state of a key half-rotated.

  4. Retire the old key. When every target has been redeployed, delete the previous key from the environment and from the portal (wrangler secret delete DIVVENT_COMMAND_SIGNING_KEY_PREVIOUS, or the dashboard). The deploy keeps an optional secret it finds, so removing it from the environment alone leaves it on the Worker.

The fingerprint is public-key material only: a digest of what the portal already publishes. The doctor reads it off the health route and holds no key to do so.

Redeploying a target as it runs. Setting DIVVENT_COMMAND_PUBLIC_KEY for the first time, or rotating it, reaches a tenant only at its next deploy through the runner, and for a target on the current pins nothing raises one:

divvent tenant redeploy <slug> --env <environment> [--instance <trial>] --reason "pick up DIVVENT_COMMAND_PUBLIC_KEY"

It raises one deploy request of the revision the target runs, and asks for the build of the history that revision landed with; deploys.yml claims it on its schedule (or dispatch it), and the runner compiles it as it landed (landedFrom, #40) — the ledger skips every step, and the artifact is the one the revision already had, now with the variables a deploy puts on it. <slug> is the application's where its account's is the same, as every test company's is, and <account>/<application> otherwise. It runs the operator function redeployTarget in process as DIVVENT_OPERATOR, from a terminal holding the control plane's .dev.vars, and takes no credential as an argument. Audited three times: api.redeployTarget, the request's deploy.deploy, and deploy.redeploy with the reason and the history. Refused where the target is behind this checkout's engine or Runtime release — a redeploy there would update its engine outside the window its customer is given (DESIGN.md §17); its fleet update redeploys it with the same variables (divvent fleet upgrade --env <environment>, or the console's Rollout screen's Retry) — and where something is already in flight on it.

**Not divvent deploy examples/<app> --via.** That compiles the directory's own .divvent/revisions/, and examples/ holds none, so for a target whose database already has the model it is a first plan, which the deployer refuses as "applied as something else". The runner's history comes from the control plane's revision store, which is why the redeploy goes through it.

After a rotation because the key leaked, a rollback puts the old key back. A Worker version carries its vars and its secrets, so:

The one question a real D1 has to answer. Whether D1's /query runs a query of several statements all-or-nothing — which the deployer's "one step, one query" rests on, and every test assumes — is asked by acceptance.yml every week, and by hand from a terminal holding staging's deploy token:

divvent control-plane d1-probe

It creates divvent-probe-atomicity-<time>, sends two statements whose second fails, counts what the first left, and deletes the database. It fails if the first statement survived. It cannot reach divvent-staging: it refuses any name not under divvent-probe-, and any database it did not create.