A Denial Is an Answer
Two rules, one incident (#3121).
π¨ A hub's configuration inherits NOTHING, and a permission check on a hub with no evaluator
answers Permission.All. So a gate written on the wrong hub is not a weak gate β it is a gate
that cannot fail.
π¨ A refusal the mesh DECIDED is an answer the operation renders. Deciding is the operation succeeding at its job. Raising it as an exception tells the caller the tool is broken.
The incident
An MCP caller without write access called recycle on Store/Catalog. The mesh refused β
correctly β and the client got this:
fail: ModelContextProtocol.Server.McpServer[1433779783]
"recycle" threw an unhandled exception.
System.UnauthorizedAccessException: Access denied: user 'rbuergi' lacks Update permission on 'Store/Catalog'
at MeshWeaver.Mcp.McpToolResult.AsToolResult[T](β¦)
MeshOperations.Recycle has carried an actionable refusal envelope since #2901 β "Recycle
requires Update permission on the target node β¦ ask someone with write access" β gated on
hub.CheckPermissionOutcome(path, Update). It never ran. Not on this call, and not on any call
through any session surface, since the day it was written.
Why the pre-flight could not fail
HubPermissionExtensions.ResolveEvaluator is three lines:
private static EffectivePermissionsDelegate ResolveEvaluator(IMessageHub hub) =>
hub.Configuration.Get<EffectivePermissionsDelegate>()
?? MessageHubPermissionExtensions.DefaultEvaluator; // (_, _, _) => Observable.Return(Permission.All)
It does not walk the parent chain β and MessageHubExtensions.CreateMessageHub builds a fresh
MessageHubConfiguration for every hub, inheriting nothing. AddRowLevelSecurity() installs the
evaluator on exactly two places: the mesh hub and every per-node hub. A session hub is
neither.
SessionHubFactory is the one factory behind every API surface β MCP, REST, gRPC, the CLI, and the
headless local sidecar all issue their operations on a portal/{prefix}-{session}-{instance} hub it
materialises. It never copied the evaluator. So every client-side check those surfaces issued
answered granted, for every caller, on every path.
Measured on a real mesh, a Viewer (Read|Execute|Api) asking the SAME question two ways:
| Asked on | Update on a node the Viewer may only read |
|---|---|
the session hub (portal/mcp-β¦) |
granted |
| the mesh hub | denied |
For a PatchDataRequest this cost only legibility β the owner's AccessControlPipeline was, and
remains, the authority on that write, and it fails closed. But not every operation's authorization
runs there. Measured before and after, same mesh, same Viewer, through a real session hub:
| Operation | Before | After |
|---|---|---|
recycle a NodeType |
THREW UnauthorizedAccessException β the reported bug |
the refusal envelope |
recycle a plain node |
{"status":"Recycled"} β the DisposeRequest went out |
the refusal envelope |
create |
Created: β¦ β the node was written |
Access denied: Create permission required |
export |
every node in the subtree | only nodes the caller may Export |
The three that were more than legibility
recycle on a plain node. Recycle is two halves of one operation: stamp a release request, then
dispose the hub. The 2026-08-30 fix
made a refused stamp refuse the whole recycle. But the stamp only writes anything on a NodeType
node β on anything else the update is the identity function, so nothing was posted, nothing was
gated, and the destructive half ran for free. Authorization that is a side effect of a write
disappears exactly when the write does.
create. AddRowLevelSecurity() registers RlsNodeValidator as a scoped service, and it
asks hub.CheckPermission(...) on whichever hub it was resolved against. A session hub opts into
WithNodeOperationExecution(), so node CRUD runs there rather than on the shared
NodeOperationExecutionHub β which is the one place that had been taught to copy the evaluator.
RlsNodeValidator therefore granted every create issued through a session, which is not a
legibility problem at all. MeshExtensions.NodeOperationExecutionHub's comment predicted this
exactly β "moving node CRUD onto a hub that did not copy the evaluator would make RlsNodeValidator
grant EVERY write" β and named the hazard for its own hub without noticing that the session hub had
the same one.
export. MeshOperations.Export filters the subtree per node against the caller's Export
permission, and its own comment said the check "runs on the mesh hub". It did not: it resolves
hub.ServiceProvider.GetRequiredService<IMessageHub>(), which on a session surface hands back the
session hub. The one thing standing between an MCP export and a subtree the caller may not
read answered granted for every node.
The fix, and why it is two halves
Half one β SessionHubFactory copies the mesh hub's evaluator into every session hub. The
pre-flight becomes a real gate, so a refusal is the normal, legible path and the destructive half
never runs. MeshExtensions.NodeOperationExecutionHub had already been fixed this way, for exactly
this reason, and its comment spells the hazard out; the session hub simply never got the same
treatment.
Nothing is widened by this: the owner already refused everything the pre-flight now refuses. A mesh deliberately built without RLS stays ungated, because there is then no delegate to copy.
π¨ Waking a gate means matching what the owner asks, not just asking something. The owner's
delivery gate stopped being a raw (path, permission) fold in
#3061/#3100: on a definitive denial it
re-decides through the node's registered INodeTypeAccessRule, which for every satellite type says
a satellite's write is Update on its MainNode. Recycle's pre-flight was a raw check, and
switching it on without that second opinion would have created a false refusal β an editor
entitled through a satellite's MainNode, whom the owner grants, refused by a pre-flight that only
knows the path. It now takes the same second opinion, from the same NodeTypeAccessRuleGate
helpers, on the same condition (a definitive denial only β never an undetermined fold, which must
stay "we could not check"). Pinned by RecyclingASatelliteFollowsItsNodeTypesRule_NotTheRawPath,
whose fixture points a satellite's MainNode into another partition: a satellite living under its
main node inherits that node's grants by path, so raw and rule agree by construction and the case
would prove nothing.
Half two β the owner's verdict is rendered, not raised. A pre-flight is check-then-act. It runs
on a different hub, at an earlier moment, against a permission fold that can have moved on: an
assignment revoked in between, a cross-silo fold, an identity lost across a scheduler hop. The owner
stays the authority, and when it refuses, RecycleCore now answers in the operation's own envelope
β the same sentence the pre-flight denial uses, because the caller should not be able to tell which
evaluator refused them, nor be told two different things about one fact.
π¨ A verdict and a non-verdict are not the same answer
Half two is where this change could have made things worse. "You lack permission" is actionable and
final; "I could not evaluate access" is transient and fail-closed. Collapsing the second into the
first sends a correctly-entitled caller to request rights they already hold β the lie #974
removed from AccessControlPipeline and #2901 removed from MeshOperations' own pre-flight.
MeshOperations.IsWriteDenial is the seam, and it decides on the typed failure β never by
matching a message, which drifts the moment someone rewords a banner:
| Fault | Denial? | Because |
|---|---|---|
UnauthorizedAccessException |
yes | the write path mints it for one thing only: DeliveryFailure{Unauthorized}, which the pipeline posts only after a decisive refusal |
DeliveryFailureException{Unauthorized} |
yes | the same verdict, before UpdateRemote maps it |
DeliveryFailureException{ShuttingDown} |
no | the activation is going away β re-probe, do not go asking for rights |
DeliveryFailureException{Unavailable} |
no | the fold reached no verdict (#974) |
TimeoutException, anything else |
no | the default must never accuse |
That the tri-state survives the hop as a type distinction is not an accident:
AccessControlPipeline projects it onto the bus vocabulary exactly once, and
MeshNodeStreamHandle.UpdateRemote keeps the split by raising MeshNodeStreamException β not
UnauthorizedAccessException β for the other two. This is the write-side twin of
NodeReadOutcome.FromReadFailure, which does the same job for reads.
Writing a new operation
- Ask the permission question on a hub that can answer it. If you are writing a
CheckPermission/CheckPermissionOutcomecall, know which hub it lands on. A session hub now answers correctly; a hub you build yourself does not, unless you copy the evaluator. - A pre-flight asks what the OWNER asks, or it is a new bug. A raw
(path, permission)fold is not what the delivery gate does any more. Take the sameINodeTypeAccessRulesecond opinion, on the same condition β a definitive denial only. - Never let authorization be a side effect of a write. Decide, then act. A stamp that happens to be gated is not a gate β it stops being one the day the stamp becomes a no-op.
- A refusal is a return value. Render it in the operation's envelope. The tool transport's
IObservableβTaskbridge is not an error handler, and an exception there reaches the client as "the tool is broken". - Never widen a non-verdict into a denial. Route it through
IsWriteDenial, or say honestly that you could not find out.
Not localised, deliberately
The refusal sentences on this surface are JSON envelope fields on the machine tool surface (MCP,
REST, CLI), consumed by an agent runtime that reasons about them and renders its own text to the
human. They sit on the model side of the line Localization draws around
tool-facing text β the same side as an LLM tool parameter's [Description] β and localising them
would make the answer depend on whichever locale happened to be attached to a session. The
human-facing surfaces render their own refusal β the portal's typed error card, via
AreaErrorClassifier β and that one is localised.
Related
- Access Control β the permission model these gates evaluate.
- AccessContext Propagation β how the caller's identity reaches the gate in the first place; a lost context is the other way a pre-flight and an owner disagree.
- An Unreachable Store Is Not a Refusal β the same verdict/non-verdict rule, on the storage side.
- Unanchored Security Reads β why "no result" and "not allowed" are the same value inside the fold, and what that forbids.
- Error Propagation & Wedges β what an unhandled fault costs downstream of a tool surface.