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

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.

Reconnecting…
The server was updated. Reloading the page to pick up the latest version.