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:

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:

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:

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

  1. Who may decide what: declared per record. The record's extraPortalConfig carries Hosting__InstanceApprovals__Kinds (for example Reconcile,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 example ApplyClusterManifest) 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).
  2. On the instance (ApprovalDecisionRelay and the copy's DecideApprovalRequest handler), 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 own ControlLane:Key and posted to control's inbox through the lane's report sink, which counts it delivered only on accepted + verified. The copy records who sent which decision, once per round.

  3. 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, requestedIdentity and 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), plus approvedOnVersion, approvalAttestedByInstance, and the router's instanceDecisionAttestation. It never writes supersededBy. The gate validates both verdicts' binding and freshness, and only then does an admitted Reject end the action Refused.

  4. The gate (ActionsExecutor.InstanceDecidedGate) takes an approval down this path only when HasInstanceDecidedApproval holds: 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).
  5. 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.

  6. Audit. The release names no control-side account for an instance's approver (ApprovalReleasedBy stays 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 records APPROVED 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:

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:

  1. File and approve on the instance. Each kind becomes a request node on the instance itself, using the Essentials OperationRequest shape 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. For InstallAddOn, the instance's Store install flow is already the authority over the install list, and the request is a governed wrapper around it.
  2. 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.
  3. 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.
  4. 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.