Approvals across instances
The boundary rule
"control should only know about the basic config of the instance, nothing more." — the maintainer, 2026-10-18
"even plugins list can be kept locally." — the maintainer, 2026-10-18
The control instance knows an instance through its deployment record, and through nothing else. The record is the boundary: host, namespace, database, keys and lanes, update policy. The instance's users, its administrators, its groups, its content, its package sources and its install list all belong to the instance. Control does not read them, mirror them or ask for them.
Everything on this page follows from that rule:
- Control sends a request to the instance the action is about. It names no recipient.
- The instance decides who sees the request, by its own rule.
- An approval whose subject is per-instance content belongs entirely on the instance. It should not pass through control at all (see Approval kinds that belong on the instance).
What happens when an action parks
"approvals re [the instance] should go to approvals app in [the instance] for global admin of [the instance]" — the maintainer, 2026-10-18
control: Ops/Actions/<id> parks at AwaitingApproval (a new round)
├─ control's own approvers: one `approvals` notice each, in control's bell (unchanged)
└─ ApprovalNoticeRelay ── ONE signed statement ──► the deployment the action is ABOUT
over that deployment's control lane (or, for an action about
(core Doc/Architecture/ControlLane → control itself, the DECLARED
"Forwarded events") operators' deployment)
target: Approvals/Workspace/_Inbox/<event>
└─ ApprovalsFromControl (Approvals package, on the Approvals app's hub)
├─ verifies the inner signature with ControlLane:Key, this deployment, an Ops/Actions path,
│ an https link to exactly that path on the stated source host
├─ writes ONE copy: Admin/_ApprovalsElsewhere/<source>-<action id> (Approvals/Elsewhere)
├─ enumerates THIS instance's global administrators (Admin/_Access Admin grants, groups expanded,
│ each confirmed by hub.IsGlobalAdmin) and raises one `approvals` notice each, pointing at the copy
└─ the copy is listed in the personal inbox (Approvals source), on Approvals/Workspace and,
where the Hosting package is installed, in the Hosting/Approvals app
control: the action leaves its park (approved and running, refused, retired, expired)
└─ ApprovalNoticeRelay ── ONE signed Closed statement ──► the same deployment
target: the copy closes; every administrator's open notice about it is put away
How the receiver stays correct
The lane delivers statements in no particular order, and a pod can restart halfway through. So the receiver
(ApprovalsFromControl.Apply) works like this:
- It folds each statement inside the write, against the copy as it stands at that moment.
- It records a closed tombstone when a Closed arrives before its Open, so the late Open opens nothing.
- It consumes a statement only after a store-backed read shows the write.
- It treats the put-away of notices (
NoticesPutAwayAt) and each administrator's notice (NotifiedAdmins) as owed until confirmed. A retry resumes only what is still owed. - It de-duplicates per snapshot generation, so a statement it kept is retried after a re-open.
- On control, a parked action that is re-targeted to another deployment closes its earlier round at the old destination.
What the statement carries
kind: approval-notice, version: 1, the destination deployment, the source deployment and
sourceHost, state (Open / Closed), outcome (closed only), the actionPath and the full
https actionUrl, actionKind, actionDeployment, title, message, requestedBy, reason,
planDigest, nodeVersion (the node version a decision would bind), roundStartedAt, parkedAt,
expiresAt and issuedAt. There is no recipient field.
The statement is signed twice with the destination deployment's own lane key: once in the envelope, which the lane verifies, and once inside, which the consumer verifies. The stored inbox node is deliberately dumb.
The sender (MeshWeaver.Plugins Hosting/InstanceAction → ApprovalNoticeRelay) and the receiver
(this package → ApprovalStatement) state the contract twice, because a client instance carries
Approvals and not Hosting. Both sides pin it: ApprovalsFromControlTests.WireNames on the sender,
ApprovalElsewhereTests.SenderStatement on the receiver.
Where it goes — declared, never derived
| the action is about | the statement goes to | declared by |
|---|---|---|
a deployment X other than control |
X |
the action's own deployment |
| control itself, or nothing | the operators' deployment | control's record: Hosting__ApprovalNotice__OperatorsDeployment (unset: control's bell only) |
| a customer instance's OWN request | nowhere new: its state already travels back (InstanceRequestStatusRelay) |
— |
An action about control itself goes to the instance its operators work on. That instance shows it to its own global administrators. That set is close to control's approvers, but it is not the same set, and that is deliberate. Control does not send its list of people, and the copy tells every reader where the action is approved and by whom. A reader who is not one of control's administrators sees the request and cannot approve it, because control's gate refuses them. The routing is declared, so an estate whose operators do not use such an instance leaves the key unset.
Only where the lane is enabled
The statement travels only where every one of these holds:
- the destination's record claims
controlLaneKeySecret, which must be the same vault object as itsannouncementKeySecretwhen it declares one; - control holds
Hosting:PlatformWebhookSecret:<id>; - the destination mounts
ControlLane:Key; - the destination lists
Approvals/WorkspaceunderControlLane:EventTargets.
These are the rules every lane statement follows. Everywhere else each attempt is one log line on
control ([Approval] … is NOT stated to <id> — <why>), and nothing else changes.
🚨 Arming the lane on an instance is a decision of its own. The same key also lets control send that instance the two lane operations (Recycle, DeleteSpace), and each of those is still approval-gated on control. Arming the lane is a record change, made per deployment.
Where an instance shows it. Every instance has the personal inbox. Its Approvals source lists the
notice, and clicking it opens the copy. Since 2026-10-18 the Approvals package is a capability without
an app tile, so on an instance without the Hosting package the list of open copies is at
/Approvals/Workspace (the desk's page when no document is named). On an instance with Hosting, the
Approvals app (Hosting/Approvals) lists them beside the instance's own items.
What the copy decides
Approvals/Elsewhere lives in Admin/_ApprovalsElsewhere. The Admin partition's read scope is
exactly this instance's global administrators, which is the audience the request is for. The copy's
page and its card show what waits, where, who asked, why, the plan digest, the expiry, and one
link: the action on control.
- The personal inbox lists the notice under Approvals. Approve or Reject there sends the copy a
DecideApprovalRequest.- For a kind the record declares, the copy sends the signed decision (see below).
- Otherwise it answers with where the request is approved (
ApprovalElsewhere.DecideRefusal).
- The row is the link (maintainer, 2026-10-18: "row click should open rather than separate open"). Clicking a
row in
Hosting/Approvalsor on/Approvals/Workspaceopens the item: its own page here, or the action on control. - In
Hosting/Approvals, the row cannot be ticked. Its status reads approved on control…. - On control,
ActionsExecutor.ApprovalGateaccepts either an approval written by one of control's global administrators (unchanged), or an instance's decision (InstanceDecidedGate, below).
The decision relay — an instance's own administrator decides
Built 2026-10-18 on the maintainer's go. For the kinds a record lists, an action filed on control about an instance is
decided by one of that instance's global administrators, on that instance. The design generalises the
existing customer path (CustomerApprovalGate and HasInstanceAttestedApproval) and adds no new credential.
Who may decide what: declared per record. The record's
extraPortalConfigcarriesHosting__InstanceApprovals__Kinds(for exampleReconcile,Roll,Restart). Control reads it from the record (InstanceApprovalDecisions.KindsOf) and the instance reads it from its own config (ApprovalDecisionRelay.KindsOf).- Undeclared means nothing is decided on the instance.
- Only kinds on a code ALLOW-list count (
DecidableOnTheInstance: Roll, Reconcile, Restart, Reboot, Suspend, Backup — not Reactivate: a suspended record admits no instance decision; the operators reactivate), whatever a record lists. A kind the platform adds later (for exampleApplyClusterManifest) is never decidable on an instance by default. Excluded, among others:- the estate, the cluster and the registry: InfraDeploy, UpgradeCluster, ApplyChart, FleetRegistryCleanup, RegistryRetention, the registry keys;
- secrets: SetSecrets and the secret kinds;
- databases: ResetDatabase, PurgeKeptDatabase;
- the instance's existence: Provision, Teardown, Restore;
- identities and diagnostics: AuditIdentities, Inventory, AnalyzeDump;
- the content kinds Recycle and DeleteSpace (see the next section).
On the instance (
ApprovalDecisionRelayand the copy'sDecideApprovalRequesthandler), Approve or Reject in an administrator's inbox is sent only when all of these hold:- the copy is open, not expired, and about THIS deployment;
- the kind is declared;
- the decider is a global administrator here;
- the self-approval rule holds under the maintainer THIS instance declares.
The decision is a signed
instance-approval-decision{deployment, actionPath, actionKind, decision, decidedBy, decidedAt, planDigest, requestIdentity, roundStartedAt, nodeVersion, issuedAt, nonce}. It is signed with the instance's ownControlLane:Keyand posted to control's inbox through the lane's report sink, which counts it delivered only onaccepted+verified. The copy records who sent which decision, once per round.On control (
InstanceApprovalDecisions, routed in the inbox before any fleet-secret verification):- the signature must verify with that deployment's own key (
Hosting:PlatformWebhookSecret:<id>, never the fleet secret), and the decision must have been issued inside the lane's 15-minute window; - the record must claim its lane key, be actionable, and list the kind;
- the action must be about that deployment, filed on control, parked, not retired, of the stated kind, with the
same
roundStartedAt,requestedIdentityand plan digest the administrator was shown, and a node version not ahead of the node.
It then writes the decision as System. An Approve and a Reject are written the same way:
instanceDecision, the approver and the binding (approvedRound,approvedRequestIdentity, the plan), plusapprovedOnVersion,approvalAttestedByInstance, and the router'sinstanceDecisionAttestation. It never writessupersededBy. The gate validates both verdicts' binding and freshness, and only then does an admitted Reject end the actionRefused.- the signature must verify with that deployment's own key (
The gate (
ActionsExecutor.InstanceDecidedGate) takes an approval down this path only whenHasInstanceDecidedApprovalholds: the write is the router's, as System, and the approval names the action's own deployment.- It does not re-check the person: control trusts the instance's assertion (the boundary rule).
- It checks the record still lists the kind (an unread record parks), the self-approval rule under the maintainer the RECORD declares, and the binding every approval carries: single use, dated, not in the future, bound to (request, action, deployment, image tag, plan), written after the park, not expired.
- Control asks no step-up of an approver who decided elsewhere (
StepsUpHere).
Replay protection. The decision must arrive inside the window, and the inbox event is deleted once it is decided. The approval is consumed on dispatch (
approvalConsumedAt). A decision for another round, request or plan is refused by name.Audit. The release names no control-side account for an instance's approver (
ApprovalReleasedBystays empty, so a same-named account here never receives their result). The result reaches the approver on their instance as the closure statement. The run's log recordsAPPROVED BY '<who>' ON <deployment> — … instance-attested(StampInstanceDecision). Control logs each admitted or refused decision with its reason.
What the instance checks before it signs, beyond the kind and the administrator:
- The decider must hold a notice of the current round. The copy records when this instance wrote the round, and an older notice decides nothing.
- The round is reserved atomically before signing, so of two overlapping clicks exactly one sends. The reservation is released if the decision was not delivered.
- Where the instance requires an approval step-up, it refuses and points at the gate on control. Owed: carrying a local step-up receipt bound to the action, round, request, plan and decision.
Only the verified relay can attest. The inbox router signs the decision as it admitted it with this
installation's operations signing key (instanceDecisionAttestation). The gate rebuilds the material from the
action and verifies it, so approval fields that any other System write introduced or preserved are not a
decision. An attested Reject is judged even where no approval is required any more. A decision waiting out an
unreadable record re-enters the whole control-plane step (Process), so supersession and request expiry are
checked again.
On control, both decisions go through the gate. An Approve and a Reject alike are written as an attested
decision carrying approvedRound and approvedRequestIdentity. The gate judges them on the revision it examines,
with the request identity recomputed live. An admitted Reject ends the action refused.
Owed: replay after consumption. The attestation proves the relay admitted a decision, but nothing yet records
that decision as spent anywhere an action writer cannot reset. A writer with Update rights on a consumed action
could restore its request, round and state, clear approvalConsumedAt, and present the same attested decision
again. The decision would still be bound to the identical request, plan and round, and it is valid for at most
24 hours. The fix is a consumption ledger the gate consults: a node per attestation in a System-only namespace,
marked spent by the write that starts the run. That fix is owed and tracked in bug triage.
Node version. The decision carries the version the administrator read, and control records it as
approvedOnVersion. Control refuses a version AHEAD of the node, but does not require byte equality. Any watcher
stamp on a parked node moves its version, so equality would refuse every decision. What binds the decision is the
round, the request identity and the plan digest.
Approval kinds that belong on the instance — the move, designed
Under the boundary rule, an approval whose subject is per-instance content belongs entirely on the instance. These
kinds do not travel through the decision relay (they are not on DecidableOnTheInstance):
| kind | subject | today |
|---|---|---|
InstallAddOn |
the instance's install list | filed on control, about the instance's packages |
Recycle |
a node or NodeType on the instance | filed on control, sent over the lane |
DeleteSpace |
a Space on the instance | filed on control, sent over the lane |
The move is too large for this change and is designed here, not built. The operations already EXECUTE on the
instance (RecycleOperation, DeleteSpaceOperation in core's control lane, and package installs in the instance's
own Store). What moves is the request and its approval:
- File and approve on the instance. Each kind becomes a request node on the instance itself, using the
Essentials
OperationRequestshape with its script hash, or a small instance-local action type. It is approved by the instance's own global administrators under the instance's self-approval rule, through the inbox and the instance's Approvals view. The operation then runs in-process, with no lane hop. ForInstallAddOn, the instance's Store install flow is already the authority over the install list, and the request is a governed wrapper around it. - Control keeps nothing about it. It holds no node and no plan, and it reads no install list ("even plugins list can be kept locally"). The fleet view sees only what the instance reports in its own status.
- Retire the control-side kinds for instances in steps:
- (a) the control Approvals view stops OFFERING Recycle and DeleteSpace for a deployment whose record arms the lane, and points at the instance;
- (b) the planner refuses to file them for such a deployment, naming the instance's own request page;
- (c) the lane operations stay only for control's own break-glass use, behind their existing gate.
- Tests pin the refusal in (b) and the instance-local approval in (1). The customer path's existing Recycle
request (
CustomerInstanceRequests) is folded into (1) as the same instance-local request.
Related
- MeshWeaver.Plugins
Hosting/ApprovalsInbox: the operations Approvals app, which also lists the copies. - MeshWeaver.Plugins
Hosting/OperationsInstance§5.1: the approval path on control. - Core
Doc/Architecture/ControlLane→ "Forwarded events": the lane, and its third exception. - Core
Doc/Architecture/CommunicationHub§6: the inbox's Approvals source.