Deployment governance
Attestation proves a node runs a trusted image today. Three later decisions could still put an app’s data in front of something new:
| Decision | Flag | What it lets in |
|---|---|---|
| Release approvals | --release-approvals |
A new mero-tee release (a new node image) holding the app’s data |
| App upgrades | --app-upgrades |
New app code reading the app’s state |
| Outbound approvals | --outbound-approvals |
A new destination the app may send data to over HTTPS |
Each is held by one side, chosen when the cluster is deployed
(system::Governance, Authority), and the same rules hold on every cluster,
shared or dedicated:
operator: the operator decides for every app, through the Ops API. Nobody else can. This suits a managed deployment whose users trust its operator with that decision.owner(the default; the attested value keeps this name): the decision belongs to the parties around each app, with a fresh passkey assertion that is logged as consent. The operator cannot. Who exactly depends on the decision:
| Decision | owner side: who holds it |
Who acts | Where |
|---|---|---|---|
| Release (node image) approvals | The app’s publisher: the organization its package’s signing key is bound to (Publishers). For a package whose key is not bound yet, or a raw bundle (development only), the app’s owning organization, or its owner account outside one | Members of that organization holding APPROVE_RELEASES (its owner and admins hold it); for an account, the account |
POST /orgs/{org}/releases, POST /auth/releases |
| App upgrades | Per app, as its package’s signed manifest declares (upgrades): owner (the default): the instance’s owner, or its organization’s MANAGE_APPLICATION holders; publisher: the publisher organization’s MANAGE_APPLICATION holders, for every instance |
The holder, with a passkey step-up over the exact upgrade | POST /apps/{app}/upgrade, POST /orgs/{org}/upgrade (owner side), POST /orgs/{org}/packages/{package}/upgrade (publisher side) |
| Outbound approvals | The app’s owning organization or account, per publisher key | MANAGE_APPLICATION holders organization-wide; the account |
POST /orgs/{org}/outbound, POST /auth/outbound |
| Who publishes a package | The holder of the package’s pinned signing key, whatever governance says | A member holding APPROVE_RELEASES in the organization, sending the key’s signature |
POST /orgs/{org}/publishers |
| Release deadlines | Always the operator (below) | The operator | POST /ops/releases/{version}/deadline |
With operator, each row’s decision is the operator’s instead
(POST /ops/releases, /ops/apps/{app}/upgrade, /ops/orgs/{org}/upgrade,
/ops/outbound). Whichever side does not hold a decision is refused by the API
(403) and, should a command get that far, by apply.
Fixed at genesis, attested with every receipt key
Section titled “Fixed at genesis, attested with every receipt key”The first system leader writes the configured governance into the system state
with SystemCmd::Genesis, before any account can exist
(The system group). After
that it cannot change: a second genesis is refused, and a node restarted with
other flags reads the state’s answer, not its own.
One part may change once: the operator may move deployments
from open to granted (SystemCmd::SetDeploymentsGranted, signed with its
key), never back, so an existing cluster can close installs without being
recreated. It only narrows who may install code; the attested governance
changes with it. The sign-up policy changes too, with the
signature of whoever runs it.
Every node’s receipt-key attestation binds the governance its system state
holds (receipt_key_binding(key, governance)). verify_app_cluster requires
every replica of an app to attest the same governance and returns it, so a user
can prove, from hardware quotes, who may move their app to new code before they
trust it with data (Attestation & receipts).
Publishers
Section titled “Publishers”A package is identified by its name and its pinned signer, the publisher
key (did:key) that signed its first release
(Packages). Who installed a
package first says nothing about who publishes it: anyone may install a
registry package. An organization becomes the publisher of every package a
key signs only by binding the key, with the key’s own signature:
GET /publishers/{signerId}answers the cluster’s name and the key’s binding generation (how many bindings it has had here).- The key signs
calimero-publisher-binding/v1\n{cluster}\n{org}\n{signerId}\n{generation}(system::publisher_binding_message), and a member of the organization holdingAPPROVE_RELEASESsends it with a passkey step-up:POST /orgs/{org}/publishers { signerId, publicKey, signature }(BindPublisher). applyverifies the signature itself (Ed25519, the key must derive thedid:key), so no node, operator or member binds a key without its holder. Each binding moves the generation on, so a signature binds once: it cannot be replayed on this cluster or another, nor after the key moved on.- Binding again (to another organization) takes a new signature from the same key; the organization it leaves cannot keep it. The pinned key never changes, so a package’s publisher is always whoever its key last signed for.
GET /orgs/{org} lists the keys an organization publishes (publishes).
Release approvals
Section titled “Release approvals”A mero-tee release is a node image: kernel, firmware, merod build. Upgrading
the cluster means starting nodes on a new release, and those nodes would then
receive app data. So the replicas that hold an app’s data decide, per app,
which releases may receive it:
- Approved set. Where the operator decides, one set covers every app
(
operator_releases), seeded with the founding node’s release. Otherwise an app’s set is its publisher’s (Org::approved_releasesof the organization its package’s key is bound to), covering every instance of every package it publishes, its customers’ included. An app whose package’s key is not bound yet, or that runs a raw bundle, is judged by its owning organization’s set, or its owner account’s outside one (SystemState::approved_for_bundle). The release of the node an organization was created through, or an account signed up through, is approved from the start. - Changing it.
POST /orgs/{org}/releases{ version, approved }by a member holdingAPPROVE_RELEASESthere (the owner and admins hold it; a member when granted organization-wide, Capabilities), or/auth/releasesby the account for its own set, each with a passkey step-up; orPOST /ops/releasesby the operator. The last approved release cannot be withdrawn.GET /orgs/{org}lists who may approve (releaseApprovers). An app or console asks for either through the node’s consent page (kind: "release", with"withdraw": trueto withdraw). A withdrawal stops new data reaching nodes on that release; it deletes and moves nothing: a replica already there keeps what it holds (and, if it leads the app, keeps leading it) until the operator moves it. - Enforcement is by the nodes that already hold the data:
-
Node::may_sendrefuses every Raft entry and snapshot of an app group to a peer whose verified attestation names a release outside the app’s set (a peer not yet attested is attested first; the system group goes to every attested peer); -
placement of a new app considers only nodes on an approved release;
-
a replica move onto a node whose release is not approved is refused.
-
every internal route that reaches an app’s data (
internal/view,call,events,storage,blob/*,app-ready,presence, an app group’sraft) refuses a calling node whose release is not in the set: peers identify themselves with their attested key (Networking); -
a session signed by a node on a release outside the set reaches none of the app (Sessions), and the app’s blobs and presence go only to nodes on an approved release.
-
An approval is a decision with a proof: the owner’s passkey consent over that exact change, or the operator’s signature where the operator decides, checked where it applies on every node, and usable once (system group). A node cannot log an approval of its own release.
So a node on an unapproved image can join the cluster and replicate the system
group, but receives no app’s data until that app’s approver approves its
release. It still holds the system state (accounts, memberships, approvals) in
full; gating that is the next phase (docs/specs/gate-system-group.md).
Release deadlines
Section titled “Release deadlines”Like a store’s target-SDK deadline, the operator may announce, per release,
when work on older images ends: POST /ops/releases/{version}/deadline { after: "2026-12-01T00:00:00Z" } (after: null, or DELETE, lifts it;
SetReleaseDeadline). It is an operator decision: the node answers 428 OPERATOR_SIGNATURE_REQUIRED with the bytes to sign, and every node checks the
signature where the deadline applies.
Deadlines are public: GET /releases, which anyone may call inside the sealed
channel, answers the governance and every deadline.
From a release’s deadline on, a party whose newest approved release is older than it (by semver) loses new work on the old image, judged on the set that governs the app (the publisher’s, or the fallback above):
- creating an app, installing a release (refused only when every scope the
installer could use it in is stopped) and upgrading an app are refused
409 RELEASE_DEADLINE, naming the release to approve and the deadline; - placement gives no new app a node on a release older than one whose deadline passed.
Existing apps keep running: their calls, reads, writes and replicas go on as
before, and no data moves. Approving the release (or a newer one) lifts the
stop at once. GET /orgs/{org} says whether its own set is stopped
(newWorkStopped).
Why the operator may do this. A deadline cannot move data (no release is
approved by it, and may_send reads only approvals), cannot block an approval
(approvals are never judged against deadlines), and cannot touch running apps.
It only withholds new work, which is availability, and availability is already
the operator’s: it can stop nodes or withhold the network. Announcing the rule
in advance and in public is better for users than an unannounced stop.
Time. apply judges a deadline against the time the command carries
(CreateApp’s createdAt, UpgradeApp’s at, InstallPackage’s
installedAt), stamped once by the proposing node and replicated with the
command, never against each node’s clock: every replica and every replay of the
log decides the same. The system log has no consensus timestamp of its own; the
proposing node is an attested node of the cluster, and a skewed clock only
moves when an announced, operator-chosen deadline takes effect, which is the
operator’s to decide anyway. An upgrade carries the time the API judged it at,
before its migration ran, so apply never refuses the record of an upgrade
the app’s group already ran.
App upgrades
Section titled “App upgrades”An upgrade switches an app to another bundle and runs its migration over the
existing state (Upgrades & migrations). Where the
operator decides, POST /ops/apps/{app}/upgrade and organization-wide runs
through /ops/orgs/{org}/upgrade upgrade to a release the operator installed
itself (/ops/packages, recorded as installed by operator). Otherwise each
app’s package says who upgrades it, in its signed manifest
(upgrades: "owner" | "publisher", calimero_bundle::UpgradeAuthority):
owner(the default when the manifest leavesupgradesout): the app’s owner may upgrade it, and so may, for an organization’s app, whoever holdsMANAGE_APPLICATIONon its team or across the organization: its owner, an admin, the team’s leads, or a member granted the capability (Organizations). A personal app stays its owner’s alone.publisher: the publisher organization’sMANAGE_APPLICATIONholders (organization-wide) upgrade every instance, whoever owns it, alone or all at once (POST /orgs/{org}/packages/{package}/upgrade { version, migrate? }). The instance’s owner cannot. Until the package’s key is bound to an organization, nobody upgrades its instances (409).
Each acts with their own passkey step-up over the exact upgrade (consent on
UpgradeApp). The declaration may only loosen toward owners: a release
declaring publisher is refused once a lower version of the package declared
owner (and an owner release below a publisher one), and an upgrade that
would move an app from an owner release to a publisher one is refused. An
owner who accepted a package as theirs to upgrade never has that taken from
them by a later release.
Outbound approvals
Section titled “Outbound approvals”An app’s signed manifest declares the URL prefixes it may call. A declared
prefix is usable only once it is approved for the app’s publisher (the
package’s pinned signer): for an organization, by whoever holds
MANAGE_APPLICATION across it (POST /orgs/{org}/outbound; a grant on one
team is not enough, since the approval covers every app of the organization),
or by the account (/auth/outbound), with a passkey
step-up, or by the operator (POST /ops/outbound). The approval records both
the prefixes approved and every prefix the approver was shown, so a prefix a
later release adds reads as awaiting approval rather than approved. The
operator additionally bounds which hosts any app may reach and holds the
secrets requests may carry (Application execution).
Deployers
Section titled “Deployers”Who may install apps (signed packages from the registry or an upload, and
raw bundles in development) is a rule of its own, chosen at deployment and
attested with the rest (Governance::deployments, --deployments):
open(the default): any account installs.granted: an account installs only with a live deployer grant: its own (account:<id>), or, holdingMANAGE_APPLICATIONorganization-wide, one its organization holds (org:<id>). A grant may end (until), judged at the install’s own time, never a node’s clock. Everyone else still signs up and creates instances of the releases they see (the operator’s catalog, their organization’s); creating one from a release the cluster lacks installs it from the registry first, and that install needs a grant. The API refuses before it fetches or stores anything (403 DEPLOY_NOT_GRANTED), andapplyrefusesInstallPackageandPutBundleagain. The operator’s own installs need none.
Who grants deployers (Governance::deployers, --deployers):
operator(the default): the operator, signed with its key (POST /ops/deployers { deployer, until? },DELETE /ops/deployers/{key};cluster / __grant_deployer / { deployer, until },__revoke_deployer / { deployer }).owner: a dedicated cluster handed to its customer. The operator grants until it names the cluster’s owner, an account or an organization, once (POST /ops/cluster-owner { owner },__cluster_owner). From then on the owner, or the owner organization’s organization-wideMANAGE_APPLICATIONholders, grant and revoke with a passkey step-up (POST /deployers,DELETE /deployers/{key},412first), and the operator’s signature is refused.
Revoking a grant stops new installs only: what the deployer installed, and
the apps running it, stay. GET /deployers lists the rule, the owner and every
grant, for the operator and for whoever acts for the owner.
Where deployments are granted, creating a deployment and
offering an app in one need the same grant, held by the account acting.
The governance JSON names deployments and deployers only when they are not
the defaults, after the other three fields, so a cluster deployed with the
defaults attests the same bytes as before
({"releases":"owner","appUpgrades":"owner","outbound":"owner"}). A client
that recomputes the receipt-key binding must keep both fields, in that order,
when the attested object carries them.
Sign-up
Section titled “Sign-up”Who may create an account (Governance::sign_up, --sign-up), checked by
CreateAccount at apply (SystemState::admits), so a leader cannot let
anyone else in:
open(the default): anyone who reaches the cluster.invite: someone presenting an organization invite (the account joins the organization at the invite’s role, the invite spent once) or a cluster sign-up invite.closed: someone presenting a cluster sign-up invite only, the pre-authorization of whoever runs sign-up.
An invite rides on CreateAccount { invite: { inviteKey, now, signature } }:
the signature is the token key’s over the new account id and now
(proof::sign_invite_redeem, the redemption an organization invite already
uses), so only the node the person handed the token to can make it, and it is
spent once (used_proofs). Its expiry is judged at the signed now.
An unknown, expired or used-up invite is refused on every policy, before
anything changes.
Cluster sign-up invites (SystemState::sign_up_invites, by the token’s public
key; CreateSignUpInvite, RevokeSignUpInvite) and the policy itself
(SetSignUp) are run by one party: the operator, with its key, until the
operator names the cluster’s owner where deployers: owner; then whoever acts
for the owner, with a passkey, and never the operator
(SystemState::runs_sign_up). Statements: cluster / __sign_up / { policy },
__create_sign_up_invite / { inviteKey, uses, expiresAt },
__revoke_sign_up_invite / { inviteKey }. Routes:
Sign-up policy.
The governance JSON names signUp only when it is not open, after
deployers; a cluster from before reads as open.
Deployments
Section titled “Deployments”A deployment is an owner’s list of the apps it offers on the cluster
(SystemState::deployments): an id, a name customers see, an owner (an
account, or an organization) and, per app name, the one installed release it
offers (package@version). Customers create instances from its offers; the
releases themselves are stored once and may be offered by several
deployments, each independent of the others.
Who manages it: for an account owner, that account; for an organization,
whoever holds MANAGE_APPLICATION there organization-wide (the owner and
admins included). Each change carries the actor’s own passkey consent,
verified where it applies on every node, and is judged against the state it
applies to, so a member whose capability was withdrawn, or who left, can no
longer change it:
| Change | Route | Consent statement | Rule at apply |
|---|---|---|---|
| Create | POST /deployments { name, owner } |
cluster / __create_deployment / { name, owner } |
The actor acts for owner; a live grant where deployments are granted; at most 16 per owner. Its id is derived from the consent’s nonce, so no node picks another |
| Offer | POST /deployments/{id}/apps { app, package, version } |
deployment:{id} / __offer_app / { app, package, version } |
A manager who sees the release, installed already; a live grant where granted; adds the entry or moves it to another release of its package; at most 64 apps |
| Withdraw | DELETE /deployments/{id}/apps/{app} |
deployment:{id} / __withdraw_app / { app } |
A manager; refused while an instance created from this deployment runs that app’s package. The release stays installed |
| Delete | DELETE /deployments/{id} |
deployment:{id} / __delete_deployment / {} |
A manager; only once it offers nothing and no instance came from it |
Each answers 412 CONFIRM_REQUIRED first. An organization that owns a
deployment is not deleted until the deployment is.
Instances: POST /apps { deployment, package, version?, service?, … }
creates an instance from the release the deployment offers for that package
(version, if given, must be it), whether or not the creator sees the
release otherwise, and without a deployer grant: nothing is installed. The
instance records the deployment (AppRecord::deployment), and CreateApp
checks again where it applies that the deployment exists and offers the
release the instance runs. Instances created without a deployment, and those
from before, record none.
Releases: DELETE /packages/{p}[/{v}] is refused for a release any
deployment offers, whoever installed it first; a release no deployment
offers keeps the rules it had (its first installer uninstalls it once no app
runs it). Records from before deployments are not moved into one.
Reads:
GET /deployments: the deployments the caller manages.GET /deployments/{id}: public by id: its name, its frontendhost, its owner’s kind and name, and the apps it offers with their releases. Never its managers or customers.GET /deployments/{id}/orgs: for its managers, on fresh state: each organization with instances created from it, with its id and name, how many, which of the deployment’s apps and the releases they run, and when the first was created, andmemberCountandactiveMembers30d(members whose last sign-in or session exchange is within 30 days). Never who the members are, teams, instance names or app data; instances outside an organization are only counted. Another deployment’s customers never appear, even on the same release.
Frontends open at a deployment’s release with /d/{deployment}/, or on the
deployment’s own host, {dlabel}.{apps-domain}, which POST /deployments and
the reads above return as host
(Deployment hosts).
Reading the approvals
Section titled “Reading the approvals”Before approving, the approver needs to see what is waiting. Read-only reports
show it, each paged (?after=ID&limit=N, at most 100 per page):
- An organization’s members:
GET /orgs/{org}/apps(any member;404to anyone else, asGET /orgs/{org}). - An account, for its own apps:
GET /auth/apps. An app’s frontend (a session bound to its package) reads only that package’s apps, so a developer’s app can tell its users that a release waits for their approval. - The operator:
GET /ops/appsandGET /ops/orgs, without names or accounts (The operator).
Each answers the governance, the scope’s approved releases (null: none ever
recorded, which leaves the apps unrestricted), the releases the cluster’s
nodes run with whether each is approved (so “2.3.131 runs here, not approved
yet” is one read), and per app:
- its release (the one the instance was created at or last upgraded to, so
releases sharing a WASM are told apart), and its publisher: the package’s
pinned signer and, once the key is bound, the organization it is bound to
(
publisher.org); releasesApprovedBy: whose approvals decide which releases hold it:operator,publisher(a bound key) orowner(the app’s organization or account). The app’sapprovedReleasesis that party’s set, which for a publisher’s package is not the scope’s own;upgradesHeldBy: who upgrades it:operator, or what its release’s manifest declares,ownerorpublisher;- newer releases it could upgrade to (one with the same WASM included: its
frontend may differ), its last upgrade (each end’s release) with the actor’s kind
(
owner,member,operator), and each replica’s release and whether it is approved.
The scope’s own approvedReleases and clusterReleases describe the
organization’s or account’s own set: the one that governs its raw uploads,
its unbound packages and, as a publisher, every instance of its packages.
Approving and upgrading stay passkey decisions on the cluster’s own origin: a
session bound to an app’s frontend is refused the approval routes (403), so
an app sends its user to the node’s consent page, below.
Asking from an app: the consent page
Section titled “Asking from an app: the consent page”Where owners decide, an app often needs its user to make one of these decisions: a new version of the app is out, a feature needs an outbound host, the operator rolled a new image. The app cannot run the passkey ceremony itself: an assertion is blind, so the page that asks for it decides what the person thinks they approve, and an app’s page is the publisher’s code. A session opened on an app’s frontend is refused every approval route.
Instead the app asks the node (POST /consent/requests), and the node serves
a consent page of its own, on its own origin, that shows what is being
approved from node-side data only (for an upgrade, a capability sheet diffed
from the two signed manifests), asks for the passkey there, and sends the
person back to the app with the result. The consent then binds the release’s
measurements digest or the target’s signed-manifest hash, not just a version.
See The consent page.
Choosing
Section titled “Choosing”Giving the operator a decision is giving it the power to put new code, a new image or a new destination in front of user data. The choice is the deployment’s, made once, and visible to every user who verifies the cluster.