Skip to content

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).

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 holding APPROVE_RELEASES sends it with a passkey step-up: POST /orgs/{org}/publishers { signerId, publicKey, signature } (BindPublisher).
  • apply verifies the signature itself (Ed25519, the key must derive the did: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).

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_releases of 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 holding APPROVE_RELEASES there (the owner and admins hold it; a member when granted organization-wide, Capabilities), or /auth/releases by the account for its own set, each with a passkey step-up; or POST /ops/releases by 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": true to 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_send refuses 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’s raft) 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).

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.

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 leaves upgrades out): the app’s owner may upgrade it, and so may, for an organization’s app, whoever holds MANAGE_APPLICATION on 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’s MANAGE_APPLICATION holders (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.

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).

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, holding MANAGE_APPLICATION organization-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), and apply refuses InstallPackage and PutBundle again. 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-wide MANAGE_APPLICATION holders, grant and revoke with a passkey step-up (POST /deployers, DELETE /deployers/{key}, 412 first), 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.

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.

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 frontend host, 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, and memberCount and activeMembers30d (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).

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; 404 to anyone else, as GET /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/apps and GET /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) or owner (the app’s organization or account). The app’s approvedReleases is 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, owner or publisher;
  • 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.

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.

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.