Approvals — how it works

Approvals are node-native: two NodeTypes the mesh compiles live from Source/, plus a node that contributes the menu entry. Nothing here is a compiled module, so installing or removing the package changes the mesh immediately — no image, no restart.

The three moving parts

Node What it is
Approvals/Approval The satellite TYPE. One approval = one {document}/_Approval/{id} node: who asked whom, what for, by when, and the decision. Its Overview shows the record (with Approve / Reject for the named approver of a pending request); its Thumbnail is the card the inline list renders.
Approvals/Desk + Approvals/Workspace The document-anchored surfaces — RequestApproval (the form) and Approvals (the live list). The ONE Approvals/Workspace instance serves every document; the target arrives per render.
Approvals/RequestApprovalMenu A UiContribution node: the Request Approval entry on every node's menu, contributed as DATA.

Why one desk instead of every hub

The retired module registered its form, its list and its menu entry onto every per-node hub of the mesh (ConfigureDefaultNodeHub) — something no in-mesh type can do, and something that put a mesh-wide feature in the platform's build graph.

The desk needs none of it. Each area resolves its document per render:

  1. an explicit ?doc=Space/Path,
  2. else the layout-area reference id an embedding page passes — Controls.LayoutArea("Approvals/Workspace", "Approvals", documentPath),
  3. else the desk node's own documentPath.

So a document page embeds the approvals list, and the menu entry links to the form with the current node in ?doc=. Precedence and normalization are pinned by ApprovalDeskTests.

Embedding the list on another type

Controls.LayoutArea(ApprovalDocs.DeskPath, ApprovalDeskAreas.ApprovalsArea, documentPath)
    .WithShowProgress(false)

Probe first if the package may be absent — a query-index existence check on Approvals/Workspace, never a hub probe of a missing node (which costs the full activation timeout). The platform's markdown overview does exactly this.

What stays platform-level

The Approval record and the _Approval → annotations satellite mapping live in the platform (MeshWeaver.Mesh.Contract). Storage placement is resolved from the path SEGMENT, not from the node type, so approvals land in the right table and keep deserializing on a mesh where this package is not installed. Access is the platform's ordinary path-based check: an approval sits inside its document's partition and is readable exactly where the document is.

Decisions are authorized server-side

An approval inherits its document's write access, so anyone who may edit the document may write ANY field of the approval — status, approver, approvalDate. A field therefore authorizes nothing. Until this was fixed, writing status: Approved through the API WAS an approval, and the e-mail send gate counted it.

The approval's own hub runs ApprovalControlPlane — the platform's authorize as the caller, execute as System shape, the same as Essentials/OperationRequest and Hosting/InstanceAction:

  1. A decision is a request. The Approve / Reject buttons (or any API write) write the status; the write's author is the one the FRAMEWORK stamped (lastModifiedBy), never a field.
  2. It is judged against the approval's TRUSTED terms — the newest revision the platform or the approval's creator wrote (TrustedRevision: remembered by the hub, else read back from version history). A change of the terms (approver, requester, document, purpose) by anyone but the requester is restored.
  3. One rule decides (Store/Core → ApprovalAuthorization.Decide, shared with Hosting/InstanceRequest and the Governance manual gates): only the named approver; never the requester (the content's requester or the node's creator) unless they are the installation's declared maintainer (Hosting:Operator:Maintainer, policy sole-maintainer-approval) — and then the trail says so; and, where the instance declares approval step-up (core Doc/Architecture/ApprovalStepUp), the decider's step-up receipt for THIS approval — bound to Approval.StepUpBinding(), the approval's terms AND the decision — consumed as the last check before ratifying. Approve/Reject leads straight on to the step-up; the endpoint stamps the receipt onto the approval as the decider, and that stamp re-triggers the judgement. A decision whose receipt does not count (none yet, another approval's, other terms, the opposite decision, another person's) is HELD — written back by nobody, never ratified, never restored — until a valid one arrives.
  4. An admitted decision is ratified as System over the trusted terms, trailed on the document ({doc}/_Activity/*, with the audit line) and notified to the requester. A refused one is restored and trailed as Approval: Refused, naming who tried and why.

Only a ratified decision counts (ApprovalDocs.Effective, and EmailApprovals.Read for the send gate): a decided approval whose current revision the platform did not write reads as PENDING, with no decision date. So the moment between a forged write and its restore decides nothing. An approval whose trusted revision cannot be established at all is held — nothing is written, because a System write would launder terms nobody can verify.

🚨 Where the trusted revision comes from, in practice. The hub REMEMBERS the newest trusted revision it has seen, and in the normal flow that is enough: the hub that judges a decision has first loaded the creator's (or the platform's) revision it replaces. The version-history fallback finds nothing on a Postgres store today — _Approval satellites live in the partition's annotations table, which keeps no history, and history reads carry no author — so an approval whose CURRENT revision is already somebody else's write when its hub activates is held for good: deciding again is judged against the same missing baseline and is held too. That happens to every decision written before this control plane existed, and to a decision whose hub died between the write and its ratification. The way out is the requester's: re-save the request (a write by the approval's creator is trusted and becomes the baseline) or file a new one, and the approver decides that. Fail-closed by design — a held approval never counts.

Deciding from the inbox

Every approval request also appears in the approver's personal inbox (/{user}/Inbox, the Approvals source). Ticking requests there and pressing Approve or Reject decides them all, through the same path as the approval's own buttons:

Invariants worth knowing

Run the Tests area on either type to execute the suites (CI asserts them green).