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:
- an explicit
?doc=Space/Path, - else the layout-area reference id an embedding page passes —
Controls.LayoutArea("Approvals/Workspace", "Approvals", documentPath), - 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:
- 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. - 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. - One rule decides (
Store/Core→ApprovalAuthorization.Decide, shared withHosting/InstanceRequestand the Governance manual gates): only the named approver; never the requester (the content'srequesteror the node's creator) unless they are the installation's declared maintainer (Hosting:Operator:Maintainer, policysole-maintainer-approval) — and then the trail says so; and, where the instance declares approval step-up (coreDoc/Architecture/ApprovalStepUp), the decider's step-up receipt for THIS approval — bound toApproval.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. - 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 asApproval: 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:
- The request's notification points at the APPROVAL (
targetNodePath= the_Approvalnode), not at the document. The inbox sends that node aDecideApprovalRequestas the approver, andApprovalInboxDecisionanswers it. It refuses an unreadable or already-decided approval, and anyone who is not the named approver. Otherwise it writes the decision withApprovalActions.DecideAndBind, as the approver. - The answer is Done only once the control plane has ratified the decision. A decision still held (for example, while a step-up confirmation is owed) answers not done, with the step-up binding, and the request stays in the inbox.
- Once a decision is ratified, the control plane files the approver's request away (dismissed and read), however the decision was made — on the approval page or from the inbox.
Invariants worth knowing
A decision is terminal, and only the named approver makes it — judged on the server.
Decidere-resolves the approval inside the owner's update lambda and refuses a second decision, so a stale tab cannot overwrite one that already happened; the control plane then authorizes the write on its framework-stamped author (see above).The activity entry is
{document}/_Activity/{id}typedActivity. That exact shape is what the running-activities stripe queries and what the Activity views bind to.Instants render in the viewer's zone; a due DATE never does. The 15th is the 15th in every zone — converting a due date shifts the deadline by a day for half the world.
The inline list is the platform's search control, and the desk decides only whether it exists.
ApprovalDeskAreas.ApprovalCardsis aMeshSearchControlover the anchoredApprovalsQuery(projected,contentnamed because the list orders by the approval's owncreatedAt, newest first), one approval per row through itsThumbnail, live, with no search box and no click-through. What the control cannot say is "no heading over an empty list", and every document page mounts this area — so a path-only probe of the same query decides whether the section (heading and list) renders at all. The list carries noWithNamespace: an anchored search hides results under an underscore segment, and every approval lives under_Approval.The record page and the card are templates.
ApprovalLayoutAreas.BuildOverviewTemplateandBuildThumbnailTemplatereturn the whole tree at once; every value binds to the approval's projection (ApprovalLayoutAreas.Project, published under/data/approvalView), which also carries which parts show — the decision buttons only for the named approver of a pending request. The header renders in its own nested slot with a skeleton. So a document page's approvals section shows its cards immediately instead of a placeholder per approval until each approval's hub has answered (core Doc/GUI/DataBinding → "Templates first, data later").A document the render names is resolved without reading the desk.
?doc=and the reference id are known on the render turn, soApprovalDeskAreas.DocumentStreamanswers at once and only falls back to the desk node'sdocumentPathwhen neither names one.
Run the Tests area on either type to execute the suites (CI asserts them green).