MeshWeaver implements row-level security through AccessAssignment MeshNodes stored directly in the mesh node hierarchy. Permissions propagate down the tree and are resolved from a live, fully reactive cache — no storage walks, no TTLs, no cache invalidation needed.

Access Control — Reactive Scope Inheritance Root Hub (global scope) LocalAssignments ∪ StaticBaselines ➜ scope grants, unioned downward _Access/Public_Access.json → Viewer (all users) _Access/Alice_Access.json → Admin (Alice) parent-scope fold ACME Hub Inherited ∪ Local ∪ Policy caps ➜ scope grants, unioned downward ACME/_Access/Bob_Access.json → Editor (Bob) parent-scope fold ACME/Project Hub Inherited ∪ Local (deny overrides) ➜ scope grants, unioned downward parent-scope fold hub.CheckPermission("ACME/Project/Task1", …) IDataChangeNotifier live push, no TTL

Permissions flow top-down: evaluating a path folds each scope's _Access grants together with its parent scope's, so a grant made anywhere on the ancestor chain is visible to every descendant path on the next emission of that scope's shared query.

🔒 The scope invariant — MainNode MUST name a partition, and is never empty

A grant is scoped by MainNode, NOT by the folder it sits in. This one sentence is the whole model, and getting it wrong is the most dangerous mistake available in the system.

{scope}/_Access/{subject}_Access     MainNode = "{scope}"     ✅ scoped to that partition
Admin/_Access/{subject}_Access       MainNode = "Admin"       ✅ GLOBAL ADMIN (the Admin partition)
Admin/_Access/{subject}_Access       MainNode = ""            🔴 ROOT — superuser over EVERYTHING

An empty MainNode is not "scoped to the folder" — it is a root grant that merely happens to be filed under it: All on every partition, every space, every plugin and every user's private home, by scope inheritance. It looks harmless in the node tree.

🚨 The rule

MainNode must name the same scope its path encodes. A grant filed at {scope}/_Access/… with any other MainNode — above all an empty one — is rejected at every write path (AccessAssignmentGuard, enforced in both CreateNode and the upsert handler, with the structural invariants — before the validators and before their System bypass).

That mismatch is the whole danger, and it is what the mesh actually had: the offending rows sat in admin.access — i.e. Admin/_Access/{user}_Access, reading as ordinary platform-admin grants — with MainNode = "" scoping them to root instead.

Admin partition ⇒ global admin. A grant with MainNode = "Admin" IS the platform-admin grant. There is no other shape, and it must be given deliberately to a named operator — essentially never. See the section below.

What is not refused, and why

A self-consistent root grant — path _Access/{subject}_Access and MainNode = "" — passes the write boundary. It is still the superuser shape, so it is worth being explicit that this is a decision rather than a gap:

Closing this remaining path means rescoping the harness's call sites first — a mechanical change worth doing, and one that should land on its own rather than inside a boundary fix.

What it actually confers (measured, memex 2026-07-28)

Identical permission sets; only the scope differs:

MainNode Materialised node_path_prefix Effective reach
"Admin" Admin Api, Comment, Compile, Create, Delete, Execute, Export, Read, Thread, Update — inside the Admin partition only (invitations, version tracking, the role catalogue)
"" (empty) The same ten permissions at ROOT — every space, every course, every plugin, every user's private home. Including Delete.

On that date 34 accounts held the root shape — 21 of them external course participants who had merely redeemed a coupon — against two correctly-scoped platform admins. They accrued one per user from 2026-07-06 onward; the two correct rows predate that (2026-05-11, 2026-06-15), which is the tell that a writer regressed rather than the model being misunderstood.

Verify from the materialised truth, not the node tree

-- 🔴 MUST return no rows — anyone here is a superuser over the entire mesh:
select user_id, permission from admin.user_effective_permissions
 where node_path_prefix = '' order by user_id;

-- the grant rows behind it:
select path, coalesce(nullif(main_node,''),'<EMPTY=ROOT>')
  from admin.access where node_type = 'AccessAssignment' and coalesce(main_node,'') = '';

🛡️ The Admin partition — global / platform admin

"Global admin" has exactly one meaning: an admin on the Admin partition. Admin is a standard partition (schema admin, created by the migration) that holds platform-level data — version tracking, the role catalogue, and the platform-admin grants themselves. (The shipped catalogs are their own top-level partitions — agents under Agent, the AI model/provider catalog under Provider — not under Admin; see NodeType Catalogs.)

A user is a global (platform) admin iff they hold Permission.All at scope Admin — i.e. there is an AccessAssignment granting them the Admin role in the Admin/_Access namespace:

Admin/_Access/{user}_Access   →   AccessObject = {user}, Roles = [ Admin ],  MainNode = "Admin"

🚨🚨 NEVER MAKE ANYONE A GLOBAL ADMIN

Global admin is not a convenience, a default, or something onboarding hands out. It is the single most dangerous grant in the system and it must be granted to a named human, deliberately, and essentially never. If you are about to add a row to Admin/_Access, stop: the answer is almost always a partition admin on the one partition they actually need.

MainNode is the whole ballgame — "" means ROOT, not "Admin"

mainNode Scope it resolves to What the user actually gets
"Admin" Admin partition ✅ platform management only — the intended shape
"" (empty) ROOT 🔴 DATA SUPERUSER — All on every partition, every space, every user's private home, by scope inheritance

An empty mainNode does not mean "scoped to the folder it sits in". The grant is scoped by mainNode, not by its path — so Admin/_Access/{user}_Access with mainNode: "" is a root grant that happens to be filed under Admin/. It reads as harmless and is catastrophic.

Verify in Postgres, never by eyeballing the folder — the materialised truth is admin.user_effective_permissions, and an empty node_path_prefix means root:

-- 🔴 Anyone listed here is a DATA SUPERUSER over the entire mesh:
select user_id, permission from admin.user_effective_permissions
where node_path_prefix = '' order by user_id;

-- ✅ Correctly-scoped platform admins:
select user_id, permission from admin.user_effective_permissions
where node_path_prefix = 'Admin' order by user_id;

This is not hypothetical. On 2026-07-28 memex had 43 accounts with an empty node_path_prefix — holding Delete, Update, Create, Compile, Execute and Export on everything — against exactly one correctly-scoped platform admin. Most were created minutes after the holder first signed in, so user onboarding was minting mesh-wide superusers, including external course participants who had merely redeemed a coupon.

Where to look — global vs. partition admins

Question Where to look Correct shape
Who is a global/platform admin? Admin/_Access/* mainNode: "Admin", role Admin. Predicate: hub.IsGlobalAdmin()
Who administers one partition (a space, a plugin, a course)? {partition}/_Access/* mainNode: "{partition}", role Admin
Who administers their own home? {user}/_Access/{user}_Access mainNode: "{user}", role Admin
Who is a root superuser (should be nobody)? admin.user_effective_permissions where node_path_prefix = '' 🔴 must be empty

Every user is admin of their own partition — and of nothing else

Each user's home partition ({user}/…) carries exactly one grant — {user}/_Access/{user}_Access, role Admin, mainNode: "{user}". That is what lets someone manage their own space: their installed courses, their exercises, their notes. It is the only admin grant an ordinary user should ever hold. Onboarding must create this and must not touch Admin/_Access.

A grant elsewhere is a deliberate act: partition admin on a space they own, or — very rarely, for a named platform operator — Admin/_Access with mainNode: "Admin".

Such a user is a platform admin — NOT a data superuser. The Admin/_Access grant is scoped to the Admin partition (it covers Admin/Invitation, version tracking, the role catalogue, …) and does not confer access to spaces or user partitions — nor to the top-level catalog partitions (Agent, Provider), which carry their own grants (e.g. the Provider/_Access Admin grant seeded by GlobalAdminSeed). Standing access is platform management (send invites, delete things, platform config); emergency changes to space/user data require an explicit elevation (break-glass) — a separate, auditable step, never standing permission. IsGlobalAdmin() reports "is a platform admin" and gates the platform features; it is not a permission override (a root _Access grant — that is the data-superuser shape — is deliberately NOT how platform admins are provisioned).

The one predicate: hub.IsGlobalAdmin()

Every "is this user a global/platform admin?" check goes through the single canonical extension — never an ad-hoc role-name (Roles.Contains("PlatformAdmin")) or root-scope (GetEffectivePermissions("")) check:

hub.IsGlobalAdmin()          // current user (resolved from AccessContext)
hub.IsGlobalAdmin(userId)    // explicit user
// ≡ hub.GetEffectivePermissions("Admin", userId).Select(p => p.HasFlag(Permission.All))

Readers that gate on it: AdminMenuGate (Invitations / Inbox tabs), UserNodeType.GetGlobalAdminTabAsync (Global Administration tab), UserProfile.

The two type-scoped exceptions: a Space the platform itself owns

"Not a data superuser" holds for every partition a person owns — there is always somebody to ask for a grant. It has no answer for a partition nobody can own: a Space with a ONE-WAY _GitSync is system-owned (AccessAssignmentGuard.IsSystemOwned) — the repo rewrites it on every sync, IsForbiddenOnSystemOwned refuses every Admin/Editor grant on it and SystemOwnedAccessRetractionHandler retracts the ones that predate the sync. Measured on memex.meshweaver.cloud 2026-09-12: MeshWeaver/_GitSync, created by the platform in a Space owned by system-security, re-imported the whole core repository on every green build (Memex#237), and the platform admin got Not found on get and "Delete permission denied for 'MeshWeaver/_GitSync'" on delete — no human could remove it through any API.

So two node types carry an INodeTypeAccessRule whose non-admin leg is the ordinary fold and whose second leg is hub.IsGlobalAdmin(userId) — the same OR GitHubActivityExtensions.TriggerAuthorizedAsSystem already applies to every sync trigger ("triggering a sync is a platform action"):

Node type Platform admin may Still on the fold Where
GitHubSyncConfig ({space}/_GitSync) Read, Delete — always, on every Space Create, Update GitHubSyncConfigAccessRule (MeshWeaver.GitSync)
Space (the ROOT node only) Read — only while the Space is system-owned Update, Delete, and every child node SpaceAccessRule.ReadAccess (MeshWeaver.Graph)

The fold itself is untouched — GetEffectivePermissions still answers None for the admin on both paths, which is what SystemOwnedSyncConfigIsVisibleToPlatformAdminsTest pins: the widening comes from the rule, consulted by all three seams (RlsNodeValidator, the [RequiresPermission] delivery gate, the delete pre-flight) through NodeTypeAccessRuleGate, so an ordinary viewer's check is byte-for-byte what it was. A sync config carries the repo, branch and last-sync state — never a credential; that is the separate GitHubCredential node in the owner's own partition. Deleting the Space of a system-owned partition is deliberately NOT widened: a paid plugin's Space is system-owned too, and its _Access entitlement grants would go with it.

Where the grant comes from (db-init)

🚨 Platform-admin grants live in Admin/_Access, never root _Access. A root _Access grant makes a user a data superuser (All on every partition via scope inheritance) — which platform admins must NOT be. An Admin/_Access grant scopes them to platform management only. Writers (GlobalAdminSeed, GrantPlatformAdmin) and readers (hub.IsGlobalAdmin) both use the Admin partition — they disagreed before 2026-06-08 (writers wrote root, readers checked Admin scope), which silently locked configured admins out of every admin tab.

Emergency / cross-partition data access is out of scope for the standing grant — it will be a deliberate elevation (break-glass) flow (audited, time-boxed), not a permission a platform admin holds by default.


Public API — start here

Application code calls two extension methods on IMessageHub. Both return IObservable<T> — compose them with CombineLatest/Select, never await. Full reference: PermissionApi.

using MeshWeaver.Mesh;

// Check a single permission for the ambient user
hub.CheckPermission(nodePath, Permission.Update);

// Get the full effective Permission set
hub.GetEffectivePermissions(nodePath);

// Explicit user identity (admin tooling, server-to-server)
hub.CheckPermission(nodePath, "alice", Permission.Update);

The rest of this page covers the internals that back those extensions: the AccessAssignment node shape, the recursive scope walk, the per-scope synced subscriptions cached on IMeshNodeStreamCache under system identity, and the RLS validator wired into the storage adapter.

Do not resolve PermissionEvaluator directly from application code — it is framework-internal infrastructure that the extension methods wrap.


Core concepts

AccessAssignment MeshNodes

Access control is managed through AccessAssignment nodes — first-class MeshNodes with nodeType: "AccessAssignment". Each assignment grants (or denies) a role to a subject at a specific scope.

AccessAssignment nodes are satellite entities stored in the _Access sub-namespace:

Node path: {scope}/_Access/{Subject}_Access
Node type: AccessAssignment
Content: {
  "accessObject": "Alice",
  "displayName": "Alice Chen",
  "roles": [
    { "role": "Editor" },
    { "role": "Viewer" }
  ]
}

On disk (file system persistence), access files live under _Access/ sub-directories:

ACME/
  _Access/
    Public_Access.json     ← All authenticated users get Viewer
    Alice_Access.json      ← Alice gets Editor
  Projects/
    _Access/
      Bob_Access.json      ← Bob gets Viewer on ACME/Projects

In PostgreSQL, access nodes are routed to a dedicated access table — the _Access path segment maps to it through PartitionDefinition.TableMappings, seeded from SatelliteTableMapping.Defaults (PartitionDefinition.DefaultSegmentTableMappings()) — separate from the main mesh_nodes table.

Each AccessAssignment node maps one subject (User or Group) to multiple roles at a given scope. Storing all roles in one node reduces trigger invocations compared to a one-node-per-role approach.

Key properties:

Property Description
AccessObject User or Group identifier
DisplayName Optional display name for the subject
Roles Array of RoleAssignment entries
Roles[].Role Role to grant or deny (Admin, Editor, Viewer, Commenter, or custom)
Roles[].Denied When true, denies the role instead of granting it

Built-in roles

Defined as static properties on Role (src/MeshWeaver.Mesh.Contract/Security/Role.cs):

Role Permissions
Admin All \| Compile
Editor Read, Create, Update, Comment, Execute, Thread, Api, Export, Compile
Viewer Read, Execute, Api
Commenter Read, Comment, Api
PlatformAdmin All \| Compile

Permission flags

[Flags]
public enum Permission          // src/MeshWeaver.Messaging.Contract/Security/Permission.cs
{
    None    = 0,
    Read    = 1,
    Create  = 2,
    Update  = 4,
    Delete  = 8,
    Comment = 16,
    Execute = 32,     // run code / launch kernels
    Thread  = 64,     // create + use chat threads
    Api     = 128,    // API-token (MCP / programmatic) access
    Export  = 256,    // download nodes as files
    Sync    = 512,    // static-repo import/export overwrite
    Compile = 1024,   // create a NodeType Release

    // Sync and Compile are DELIBERATELY excluded from All.
    All = Read | Create | Update | Delete | Comment | Execute | Thread | Api | Export
}

🚨 Sync and Compile are not in All on purpose, and the exclusion is load-bearing. hub.IsGlobalAdmin() is HasFlag(All), and a read-only-capped Admin's effective set is folded against the role's integer value — folding a new bit into All would silently require the PG user_effective_permissions table to be re-materialised before any admin check passes again (that is the shape of the 2026-06-08 admin lock-out). The built-in roles grant Compile explicitly instead, so All — and every HasFlag(All) gate — stays byte-stable.

The System identity is the one exception: GetEffectivePermissions short-circuits it to All | Sync | Compile, so an explicit CheckPermission(System, Compile) passes.


Permission evaluation

Permissions are evaluated by PermissionEvaluator — an internal static class (src/MeshWeaver.Mesh.Contract/Security/PermissionEvaluator.cs) whose methods are pure functions over IMessageHub + the process-wide IMeshNodeStreamCache. There is no per-hub evaluator instance, no IMemoryCache layer, and no per-process mutable state: all per-scope state lives in the shared stream cache under well-known query keys.

No storage walk on the read path. No TTL cache to invalidate. Live updates ride the cached queries' own change feeds.

Two reads per path: the partition, and the root

For a target path ACME/Project/Task1, ObserveEffectiveAssignments issues exactly two anchored reads and unions them with the static baselines:

partition "ACME"  → cache.GetQuery("$security-access:ACME")   path:ACME scope:descendants nodeType:AccessAssignment
root scope ""     → cache.GetQuery("$security-access:")       namespace:_Access nodeType:AccessAssignment
static baselines  → IStaticNodeProvider
                     └─ UnionByPath + DistinctUntilChanged

_Policy nodes fold the same way under $security-policy:{partition} / $security-policy:. Both keys are cached process-wide, so every hub in the process shares ONE upstream subscription per partition however many paths, hubs or viewers consult it.

Why the partition and not the scope. A grant lives at {scope}/_Access/{id}, and every scope on a path's chain except the root is a prefix of that path — so all of them live in the path's own partition, which is exactly where _Access is stored (one schema per partition; the _Access segment routes to that schema's access table). One partition-wide read therefore answers the whole chain.

Reading per SCOPE instead multiplied that one read by the depth of the path and by how many paths were checked, because a node's own path is always the LEAF of its own chain — so every node ever permission-checked minted its own live $security-access:{path} + $security-policy:{path} pair. Measured (issue #3093, SecurityQueryScaleTest): RLS-filtering a 4-node listing opened 13 security queries, a 32-node listing 69 — exactly +2 per node, and the population never fell below the stream cache's idle window. After: 5 either way.

Why the verdict cannot change. The fold never depended on which scopes were READ, only on which are CONSULTED. ComputeScopeRoles buckets whatever nodes it is handed by each node's OWN namespace, and ComputeRoleState then walks GetScopeHierarchy(nodePath) and reads only the buckets on the target path's chain. A partition-wide read is a strict superset of the per-scope walk, so no grant — and, more importantly, no group-scoped DENY — can go missing. That direction is the one that matters: a short read in this fold is indistinguishable from "denied", and a missing deny fails OPEN (see Unanchored Security Reads).

Which provider serves which leg — worth knowing before changing either shape again, because the two legs are served by different code and only one of them is exercisable without Postgres:

Leg Query Classified Served by
grants path:{partition} scope:descendants nodeType:AccessAssignment satellite-targetedPartitionDefinition.IsSatelliteNodeType("AccessAssignment") is true (_Accessaccess) the in-repo StorageAdapterMeshQueryProvider on every backend (DefersToNativeProvider returns false for a scoped satellite read)
policies path:{partition} scope:descendants id:_Policy nodeType:PartitionAccessPolicy content_Policy is not a configured satellite segment, so these live in mesh_nodes deferred to the native per-schema delegate on Postgres

That classification is what makes the grant read correct rather than lucky: a query that did NOT target satellites has satellite-path rows filtered out of its result (RunQueryNodes, mirroring PG's separate-table routing), so an anchored scope:descendants read of {partition} would silently return no _Access rows at all. It is the nodeType: filter — configuration, not the _ character — that keeps them in.

The Admin exception is gone, absorbed rather than deleted. Admin is excluded from cross-schema global search (searchable_schemas), so a namespace-only query never reached admin.access and platform-admin grants silently never loaded; the fold used to special-case Admin-rooted scopes onto a path: query for exactly that reason. Every partition now takes that route, so there is no branch left to get wrong.

The other shared query keys, all on the same cache, are still global by necessity: $security-roles (the custom Role catalogue), $security-memberships (every GroupMembership node — group access is resolved globally, because a group defined in one partition can be granted in another), and $security-gated:{type} (one per NodeTypeGate). Anchoring those to a partition is the truncation Unanchored Security Reads forbids; anchoring the per-partition legs above is not, because their subject is in that partition by construction.

Evaluation flow

The check is a fold over those cached observables — no cross-hub permission request exists.

sequenceDiagram participant Client participant Pipeline as AccessControlPipeline (on the target hub) participant Eval as PermissionEvaluator (static) participant Cache as IMeshNodeStreamCache Client->>Pipeline: deliver MyMessage[RequiresPermission(Update)] target=ACME/Project Pipeline->>Eval: HasPermission(hub, "ACME/Project", userId, Update) Eval->>Cache: GetQuery($security-access:{each scope on the chain}) Eval->>Cache: GetQuery($security-policy:{each scope on the chain}) Eval->>Cache: GetQuery($security-memberships) / $security-roles / $security-gated:{type} Cache-->>Eval: unioned snapshots (shared subscriptions) Eval->>Eval: expand groups, ComputeRoleState per scope (closest-wins + deny), apply policy caps + gate grants Eval-->>Pipeline: IsGranted=true Pipeline->>Client: invoke handler (or DeliveryFailure on Unauthorized)

The user identity rides on the in-flight delivery's AccessContext.ObjectId; ResolveUserId falls back to WellKnownUsers.Anonymous.

Two — and only two — blanket short-circuits exist before the fold: WellKnownUsers.System (→ All | Sync | Compile) and the mesh-node cache's hydrator identity cache/mesh-node-cache (→ Read only). There is deliberately no global-admin short-circuit — see "The Admin partition" above.

🚨 The fold can produce NO answer, and that is a third outcome

GetEffectivePermissions is a CombineLatest over the grant and policy reads of the target's scope and every ancestor scope — plus, through its Zip against the Public evaluation of the same path, a second copy of that same fold. CombineLatest emits only once every leg has emitted.

A leg that starves never emits, never completes and never errors: SyncedQueryMeshNodes gates on SeenInitial over a merge containing a Subject that is never completed, so it can only stall. The fold therefore has no terminal at all, and a .Take(1) around it bounds the number of emissions rather than the wait. This is not hypothetical — it is the ordinary cross-silo shape, where the owning activation lives on a peer silo that is busy or has just gone away.

Not every leg is like that, and the difference is deliberate — see the convergence contract below for the rule that decides it. ObserveGatedNodes starts with .StartWith(empty) precisely so a slow leg cannot stall the fold; the grant, policy and membership legs must not be seeded. So an unanswerable read cannot be projected onto the yes/no axis at all. It needs a third outcome:

Outcome Means Reported as
granted the fold decided yes operation proceeds
denied the fold decided no Unauthorized — a statement about the caller's entitlements
could not be established the fold reached no decision NodeRejectionReason.UnavailableNode{Creation,Deletion,Move}RejectionReason.Unavailable

The message gate has the same vocabulary but a narrower reach: CheckPermissionOutcome catches a faulted fold as UndeterminedErrorType.Unavailable. It deliberately carries no bound (see its "No Timeout here" comment), so a silent starvation still parks a [RequiresPermission] delivery there. Giving the gate a terminal for that case changes the behaviour of every gated message and wants its own argument; the node-operation validator below is bounded today.

Decision callers give the check a terminal; live subscribers do not. RlsNodeValidator bounds its whole chain with MeshOperationOptions.PermissionEstablishmentBudget (default 20 s — comfortably above any healthy cold fold, strictly below the 60 s hub RequestTimeout) and answers Unavailable past it. That is not a ceiling that turns a slow check into a denial: reporting a stalled read as a refusal sends a correctly-entitled caller to request permissions they already hold, and files an availability incident as a policy decision so nobody goes looking for the read that starved. It is still fail-closed — the operation does not proceed; it simply stops claiming to know why. Same vocabulary and the same reasoning as CompilationStatus.Unavailable for a starved source read, and as PermissionCheckOutcome.Undetermined, which already gives the message gate this distinction for a faulted fold.

A live UI subscription is the opposite case and keeps no bound: it is not owed an answer by a deadline, and it must re-emit when the grants finally land.

Before this existed, CreateNodeRequest simply sat Executing — 33 s in the reported case — until its caller's RequestTimeout ended it, which names the caller's impatience rather than the read that starved (#1446).

🚨 The convergence contract — which legs may be seeded, and why the gate is not bounded

The fold's liveness problem has one obvious cure — give every leg a .StartWith(empty) so CombineLatest can never park on a slow source — and it is wrong for three of the four legs. The rule that decides it is monotonicity:

A leg of the permission fold may carry an empty seed if and only if its contribution is purely ADDITIVE. A leg that also SUBTRACTS must never be seeded: the seed drops its subtractions, and the fold's first emission is then more permissive than the truth.

leg additive contribution subtractive contribution seeded?
ObserveGatedNodes GateGrant ORs in Read on a declared public surface none — "a gate NEVER subtracts" yes, StartWith(empty)
ObserveEffectiveAssignments every runtime AccessAssignment grant Denied role assignments, from the same nodes (ComputeScopeRoles returns Granted and Denied) ❌ no
ObserveScopePolicies PublicRead GetPermissionCap(), BreaksInheritance — and their absence widens ❌ no
ObserveAllMembershipNodes group grants reach the viewer the same subject set decides which denials match ❌ no

Why the first emission is the whole story. AccessControlPipeline runs hub.CheckPermissionOutcome(…).TakeDecisionOutsideGate(), and TakeDecisionOutsideGate is a Take(1). The fold's first emission is the verdict for every [RequiresPermission] delivery. A seed is not a "brief pre-load window that settles a moment later" — from the gate's point of view it is the answer, permanently.

Each refusal, concretely:

The conclusion, and it is a strong one: there is no permissive seed that is not a hole and no conservative seed that is not a spurious denial. The fold genuinely needs its inputs, so a starving leg's only sound terminal is an error — which the fold already propagates as PermissionCheckOutcome.UndeterminedErrorType.Unavailable (retryable, and attributed to the read rather than to the caller's impatience). Making a silent starvation produce that error is a query-layer change, not a fold change: MeshQuery already detects it (InitialStallProbe, 20 s, whose own warning says "Fix the stalled provider; never bump the consumer's timeout") and deliberately only logs. Turning that probe into a terminal would change every query in the mesh and is a platform decision in its own right — it is tracked, not slipped in under a symptom fix.

The gate stays unbounded, deliberately. AccessControlPipeline carries no Timeout (see its "No Timeout here" comment) and this change does not add one. Bounding the shared gate would change the behaviour of every [RequiresPermission] message; more importantly a ceiling there cannot attribute anything — it would only say "the gate ran out of time", never which read starved. That attribution is exactly what RlsNodeValidator's PermissionEstablishmentBudget provides on the node-operation path, and it is why the bounded twin's Unavailable is a better answer than the unbounded path's 60 s caller timeout even though both describe the same event.

Silence is not consent (#2742). The fold has three terminals, not two — emit, fault, and complete without ever emitting — and only the first two were represented. CombineLatest completes the moment any source completes having produced no value, so one silent leg empties the whole fold; CheckPermissionOutcome passed that empty completion straight through; and an empty check is not a refusal anywhere downstream — the pipeline's .Take(1).Select(…).DefaultIfEmpty() reads null as "no check refused ⇒ every check was granted" and invokes the handler. Measured before the fix: a [RequiresPermission(Read)] GetDataRequest was delivered and answered normally with no failure at all — a full authorization bypass, reachable through the public WithPermissionEvaluator seam. CheckPermissionOutcome now materialises that terminal as Undetermined, so it fails closed and reports Unavailable rather than vanishing.

Both consumers had the same hole, so both were closed. RlsNodeValidator — the node-operation gate — ends its chain in TakeDecisionOutsideGate().Timeout(budget).Catch(…), which covers a value and a fault. Take(1) on an empty fold completes empty; Timeout forwards that completion unchanged (it bounds silence before a terminal, not an empty one); the Catch never fires; and the validator emits no NodeValidationResult. RunCreationValidatorsObs's Concat skips a validator that yields nothing, so "no verdict" read as "nothing objected" — measured: a caller holding no grant at all created a node under a silent evaluator and got back State = Active, CreatedBy = <that user>. It now ends in .DefaultIfEmpty(UnestablishedCheck(…, cause: null)), whose message names the silent case apart from the stalled and the faulted one, because the three have different causes.

The rule, stated once: wherever a permission answer is consumed, "no outcome" is not consent. A chain that can end without emitting must materialise that ending as a refusal — never leave it empty for a downstream DefaultIfEmpty / Concat / Where to read as "nothing objected".

Pinned by PermissionFoldSilentTerminalTest (message gate) and RlsSilentFoldWriteTest (node operations), each with an over-reach control proving a healthy fold on the same mesh still answers; the seeding rule above is pinned by PermissionFoldLegSeedGuardTest.

🚨 The budget is DERIVED, never configured beside the bound it sits inside (#1198)

A bound that is nested inside another bound only earns its keep by firing first — it is the only level that knows which read starved; the level above it can say no more than "the operation ran out of time". That ordering was left to coincidence and duly failed: on the delete path the enclosing MeshOperationOptions.Timeout, the descendant handler answering the pre-flight fan-out, and this establishment budget were three independently-configured constants all reading 30 s. Equal is not an ordering — the outer clock starts first — so the innermost bound could never win, and every starved delete reported the caller's timeout instead of the read.

There is now exactly one configured value, MeshOperationOptions.Timeout, and every nested rung is derived from it by MeshOperationOptions.Nest, which contracts strictly:

rung what it bounds default
Timeout the mesh operation, as its caller bounds it 30 s
NestedTimeout a handler running inside one of that operation's stages — a descendant answering ValidateDeleteRequest, a cascade leg re-entering the delete handler 25 s
PermissionEstablishmentBudget one authorization fold inside such a handler 20 s

RowLevelSecurityOptions is gone: an independently-settable inner budget is exactly what drifted. The contraction parameters (NestingReserve, MinNestingFraction) refuse a non-contracting value, so the collision is unrepresentable rather than merely absent. The reserve is deliberately generous about the delay between the outer clock starting and the inner one starting (post, routing, warm activation); when a genuinely cold hub exceeds even that, the outer bound firing is the correct answer — "the hub never answered" is what went wrong. The inner bound exists to attribute a starved read, not a slow start.

Reactive update semantics

When an AccessAssignment is created at scope S:

  1. The shared $security-access:{S} query emits an Added delta (driven by the storage change feed).
  2. Every scope chain that includes S re-unions and re-emits — DistinctUntilChanged suppresses no-op re-emissions.
  3. The next hub.CheckPermission / hub.GetEffectivePermissions on any descendant path reflects the new assignment.

When a user joins or leaves a group, the GroupMembership node change lands on the global $security-memberships query, the viewer's transitive group set is re-expanded in memory, and subsequent checks see the updated set.

On PostgreSQL the SQL listing path is a separate materialisation — user_effective_permissions, rebuilt by trigger (see "PostgreSQL integration" below). The evaluator above answers exact reads and every UI gate; the SQL fold answers query listing. They must agree.

Closest-wins semantics

When the same role is assigned at multiple levels, the deepest assignment wins:

Scope Assignment Effect
"" (global) Alice: Admin Grants All permissions globally
ACME Alice: Admin (Denied) Overrides global grant — no Admin at ACME
ACME/Project Alice: Editor Grants Editor at ACME/Project

At ACME/Project, Alice has Editor permissions (Read + Create + Update + Comment) but not Admin.

Deny override

A deny assignment blocks an inherited grant for a specific role, but does not affect other roles. Each node's Roles[] array can mix grants and denies:

Global:      Alice_Access → roles: [{ role: "Admin" }]
ACME:        Alice_Access → roles: [{ role: "Editor" }]
ACME/Secure: Alice_Access → roles: [{ role: "Admin", denied: true }]

At ACME/Secure, Alice has Editor permissions (inherited from ACME) but not Admin (denied at ACME/Secure).


Node type architecture

Access control uses these shipped node types:

AccessAssignment

User

Group

GroupMembership

Role


PermissionEvaluator — internal, static, read-only, 100% IObservable

PermissionEvaluator is an internal static class in MeshWeaver.Mesh.Contract — a pure algorithm over IMessageHub + the process-wide IMeshNodeStreamCache. It is not a DI service, not per-hub, not a singleton object: there is nothing to resolve and nothing to mock. Application code never touches it directly; go through hub.CheckPermission / hub.GetEffectivePermissions (see PermissionApi).

There is no SecurityService class any more, and no write surface on the evaluator. AddUserRole, RemoveUserRole, SetPolicy, RemovePolicy, SaveRole do not exist. Grants are ordinary MeshNodes: create/update them with meshService.CreateNode(...) / workspace.GetMeshNodeStream(path).Update(...) like any other node, and the shared $security-* queries pick the change up.

Roles and baseline AccessAssignments follow the Extensible Defaults pattern — built-ins ship via IStaticNodeProvider (including the read-only _Policy at the root namespace) and mesh-level extensions live as user-created MeshNodes. CollectStaticAccessAssignments / CollectStaticPolicies fold the static layer in synchronously, unioned with the two anchored reads, so a statically declared grant resolves on the first emission without waiting for storage.

The read surface

internal static class PermissionEvaluator      // src/MeshWeaver.Mesh.Contract/Security/PermissionEvaluator.cs
{
    // The path is always explicit — the evaluator is not bound to a hub's own address.
    IObservable<bool>       HasPermission(IMessageHub hub, string nodePath, Permission permission);
    IObservable<bool>       HasPermission(IMessageHub hub, string nodePath, string userId, Permission permission);
    IObservable<Permission> GetEffectivePermissions(IMessageHub hub, string nodePath);
    IObservable<Permission> GetEffectivePermissions(IMessageHub hub, string nodePath, string userId);

    // Catalogue / policy reads — all reactive, all from the same shared cache.
    IObservable<Role?>                   GetRole(IMessageHub hub, string roleId);
    IObservable<Role>                    GetRoles(IMessageHub hub);
    IObservable<PartitionAccessPolicy?>  GetPolicy(IMessageHub hub, string targetNamespace);
    IObservable<string?>                 GetRedirectOnDenied(IMessageHub hub, string targetNamespace);
}

AddRowLevelSecurity() wires PermissionEvaluator.GetEffectivePermissions into every hub's MessageHubConfiguration as the EffectivePermissionsDelegate. Without that registration the default delegate returns Permission.All (no gating), which is why hub.CheckPermission always emits true on a mesh that never called AddRowLevelSecurity() — call sites are identical either way.

No Task returns anywhere on the surface — every method returns IObservable<T>. Bridging to Task from hub-reachable code is the canonical deadlock pattern (see Asynchronous Calls); the only sanctioned bridge is at the test edge.

Why per-scope caching, not per-user

The evaluator holds no per-process mutable state: no _permissionCache, no _policyCache, no _customRoleCache. Every cached observable lives in the process-wide IMeshNodeStreamCache keyed by scope, not by user — one upstream subscription per scope shared by every user and every hub. That is what removed the old per-user MemoryCache + 2-second Timeout() fallback, which fired hundreds of times per chat-thread render (every cold scope, every new user, every eviction) and is why a Timeout fallback is no longer needed: static baselines resolve synchronously and an empty scope simply emits an empty result.

Writes are ordinary node writes

Creating or editing a grant is workspace.GetMeshNodeStream(path).Update(...) (or CreateNodeRequest / DeleteNodeRequest for lifecycle) — exactly like every other MeshNode; see GetMeshNodeStream().Update() is the only mutation API. The write goes through the usual validator chain (RlsNodeValidator, AccessAssignmentGuard) and the usual persistence path; the shared $security-access:{partition} query then re-emits and subsequent checks reflect it.


Anonymous and Public access

MeshWeaver distinguishes between two well-known user groups:

User Constant Meaning
Anonymous WellKnownUsers.Anonymous Unauthenticated visitors (not logged in)
Public WellKnownUsers.Public Baseline permissions for all authenticated users

When no user context is available (empty userId or virtual user), permissions are evaluated for the Anonymous user. Authenticated users automatically inherit Public permissions in addition to their own.

A grant to either is an ordinary AccessAssignment node — there is no dedicated API:

// Grant Anonymous users read access to the Welcome page.
// MainNode MUST equal the scope the path encodes (AccessAssignmentGuard enforces it).
meshService.CreateNode(new MeshNode("Anonymous_Access", "Welcome/_Access")
{
    Name = "Anonymous Access",
    NodeType = AccessAssignmentNodeType.NodeType,
    MainNode = "Welcome",
    Content = new AccessAssignment
    {
        AccessObject = WellKnownUsers.Anonymous,
        Roles = ImmutableList.Create(new RoleAssignment { Role = "Viewer" })
    }
}).Subscribe(_ => { }, ex => logger.LogWarning(ex, "grant failed"));

// Reading back is the same reactive check every other call site uses.
hub.CheckPermission("Welcome", WellKnownUsers.Anonymous, Permission.Read)
    .Subscribe(allowed => /* ... */);

🧩 Library-seeded nodes need a library-seeded grant — the Templates partition

A partition whose nodes are seeded by library code must have its access grant seeded the same way, in the same call. Otherwise the nodes exist on every mesh and are usable on none of them except where an admin happened to grant the right by hand.

The Templates partition is the worked example. It holds the built-in "operations as scripts" Code nodes — Templates/Export/{Pdf,Docx} (seeded by AddMarkdownExport()) and Templates/Import/{NodeCopy,Mirror} (seeded by AddGraph()). Running one posts an ExecuteScriptRequest at the template, which is gated by [RequiresPermission(Permission.Execute)] on the template's own path. The templates shipped with no grant at all, so every non-admin's export died at the click with "Access denied: user 'x' lacks Execute permission on 'Templates/Export/Pdf'" (issue #423). The gate was correct — the missing grant was the bug.

ScriptTemplates.PublicExecuteGrant() is that grant, seeded via builder.AddMeshNodesIfAbsent(...) from both call sites (either alone must land it; both together must land it once). Three properties make it the minimum, not a widening:

Choice Why
Public, not Anonymous A run writes its Activity into the caller's home (ActivityParentPath = "{viewer}"), which a signed-out visitor does not have. Granting Anonymous would buy nothing.
Viewer (Read + Execute + Api) Execute is what the gate checks; Read resolves the node. Viewer is the narrowest built-in role carrying Execute and grants no Create/Update/Delete — a user may run a template, never change one.
MainNode = "Templates" Scoped to the partition, per the scope invariant above. An empty MainNode here would be a root grant for every authenticated user.

🚨 Why this is seeded alongside the nodes and NOT as a migration

Doc's equivalent Public/Anonymous grant is seeded both ways — statically in AddDocumentation() and as PG rows by DocumentationBackfill. That second half exists because doc pages are backfilled into the doc schema, so the SQL fold and partition_access need real rows.

Templates has no such half. Its nodes are AddMeshNodes statics served in-memory by StaticNodeQueryProvider; they never reach Postgres, on a fresh mesh or a long-lived one. A migration therefore could not cover them — it would write grant rows into a templates schema that does not exist and that nothing reads. Seeding the grant where the nodes live is the only placement that covers a fresh mesh and an existing deployment identically: both get it from the next image, with no backfill.


Type-declared subtree gates (NodeTypeGate)

A node type that owns an entitlement-gated subtree — a storefront plugin, a paid course — declares its access shape once, on the type, instead of materialising it per instance:

builder.ConfigureNodeTypeAccess(access => access.WithGate(new NodeTypeGate("Store/Plugin")
{
    PublicSurfaces = [NodeTypeGate.Self, "Overview", "Subscribe"],
    RedirectOnDenied = "Subscribe",
}));

Read as: every node of type Store/Plugin keeps its cover (Self), its marketing page and its checkout surface readable by everyone — anonymous visitors included — and a reader denied anywhere beneath it is sent to {plugin}/Subscribe. Nothing else is written. No _Policy node, no per-child deny, no root grant.

The rest of the model falls out of what the framework already does:

Requirement Mechanism
Everything except the declared surfaces is closed The framework's deny-by-default — no grant, no Read
Purchase / coupon opens the whole subtree ONE Viewer AccessAssignment at the plugin root; grants inherit downward
Denied reader is redirected NodeTypeGate.RedirectOnDenied, resolved relative to the gated node

Two properties worth relying on

A gate only ever GRANTS. It never denies, never caps, and never removes a permission a role or an entitlement confers. A declaration that can only widen a short, explicitly listed set of paths cannot lock anyone out and cannot regress an existing deployment — which is why an actual _Policy node still wins over the type-declared redirect, and why the older allow-then-deny gate keeps working unchanged next to it.

Self opens the node and nothing beneath it. That asymmetry is the reason the gate must live on the type at all: an AccessAssignment at the plugin root inherits strictly downward, so opening the cover that way opens the whole subtree — which is exactly why the materialised shape had to write a deny for every non-public child to claw it back.

Why not materialise it per instance

Measured on memex, 2026-07-28 (issue #701), the per-instance shape failed three separate ways:

A declaration on the type has no version counter to churn, no second condition to drift from, and cannot be "not run" for an instance. A plugin that declares no price is gated for the same reason every other one is — its type.

Cost, and the evaluators

PermissionEvaluator resolves a target path's nearest gated ancestor-or-self from one process-wide cached query per gated node type ($security-gated:{type}) — bounded by the number of gated nodes, not their children, and seeded with the static providers so a statically declared plugin resolves on the first emission. A mesh that declares no gate subscribes nothing and runs the exact fold it ran before.

⚠️ The SQL fold does not yet know about gates. Postgres RLS decides query listing; the evaluator above decides exact reads and every UI gate. Until the gate lands in the SQL predicate, a declared public surface is readable by path but will not appear in an anonymous search. The asymmetry is strictly in the safe direction — SQL is stricter, never looser, so it cannot become a bypass — but it is a real gap, not a design choice.


API tokens and the Api capability

An API token (mw_…, used by MCP and every programmatic client) authenticates as its owner and gets that person's permissions — no more. On top of that it must clear one extra gate, the API-token clamp in PermissionEvaluator:

// a Bearer context that cannot reach the API surface here gets NOTHING here
if (currentContext?.IsApiToken == true && !p.HasFlag(Permission.Api)
    && !PublicSurfaceCarriesApi(publicGrant, permissionCap))
    p = Permission.None;

It zeroes the whole permission set, not just the Api bit — "may not use the API here" is not a partial answer. There are exactly two ways past it, and both are read live off the target path on every evaluation:

  1. The caller's own node permissions carry Api. Every built-in role carries it (Viewer, Commenter, Editor, Admin), so an ordinary grant is enough; a custom Role that omits Api is the case where a real grant still leaves the token outside.
  2. This path's PUBLIC surface carries it — a PartitionAccessPolicy.PublicRead scope or a declared NodeTypeGate segment — and no policy on the scope chain caps Api out. A page every anonymous browser may read is not secret from an API client. This is what keeps tokens working on Doc/, Agent/ and every installed package partition, which PackageInstaller publishes through exactly that policy rather than through an AccessAssignment.

PartitionAccessPolicy { Api = false } is therefore meaningful in its own right: "readable in a browser, not reachable through the API." The public grant is ORed in after the cap so the page stays readable; the capability it confers is not, so the API surface closes. An inherited public grant is still subject to a deeper Read = false policy, as described below.

🚨 Why the mint-time role snapshot existed — and why trusting it was the bug

ApiToken.Roles is a list of role ids captured when the token was created. It rides ValidateTokenResponse.Roles and is stamped onto AccessContext.Roles by UserContextMiddleware. Until 2026-09-01 the clamp's second escape hatch was that snapshot (ClaimsCarryApi), and the comments around it gave a reason:

per-node hubs intentionally don't register the synced AccessAssignment query (recursion avoidance), so without the stamp an API-token request sees 0 roles → 0 perms → the gate strips → DENY.

That reason had been obsolete for months, and the comment outlived the mechanism it described. Two things had changed underneath it:

A snapshot answers a question about now with a fact from then, so it was wrong in both directions at once:

What a stale snapshot does Consequence
Too restrictive It cannot see a grant made after the mint A token minted before its owner held anything Api-bearing could not read a publicly-readable partition the same person's browser renders fine — and no later grant could fix it, because no later grant rewrites a minted token. Most IdPs emit no role claims at all, so ApiToken.Roles is usually empty: re-minting produced the same empty list and changed nothing.
Too permissive It cannot lose a capability revoked after the mint A token whose mint-time claims carried an Api-bearing role name kept the API surface open forever, over the top of a PartitionAccessPolicy written afterwards that said api: false. Withdrawing API reach could not withdraw it from the tokens that already existed.

The second row is the security half, and it is the one that decided the design: the failure that must not ship is a token retaining authority someone took away.

The fix, and why this shape

The capability is now derived from (publicGrant, permissionCap) — two values the fold already computes for this path on every emission. Concretely that buys freshness for free:

AccessContext.Roles is now read nowhere in PermissionEvaluator, and nowhere else that decides anything. It is still carried on a Bearer context as a diagnostic, and that is all it is — a future "just check the claims" is a regression, not a shortcut.

Closed by #2976 — the clamp reaches the routed path too. Until that fix, Roles had one remaining non-diagnostic use: AccessControlPipeline restored the sender's AccessContext on a receiving per-node hub only when delivery.AccessContext carried a non-empty Roles list. A token minted with no claims — the ordinary case — therefore arrived with no restored context at all, capturedContext was null, IsApiToken was never seen, and the clamp did not run on a message-routed check. It was never a hole in the read path (the exact-read gate MeshNodeStreamCache.GetStreamRawProbeEffectivePermissions captures the caller's context itself and clamps correctly), which is why it survived unmeasured for so long. The restore is now keyed on the delivery carrying a real principal, not on role claims — see Restoring the caller on the receiving hub. Pinned by RoutedApiTokenClampTest.

🚨 The general rule this is an instance of. A credential must not carry a copy of an authorization fact. Copies go stale silently and in both directions, and the permissive direction has no expiry: the moment authority is snapshotted onto a token, revoking it stops working for every token already minted. Authority is read from the authority, at the point of use.

Pinned by ApiTokenCapabilityFreshnessTest (core) and PaywallRealGateShapeTests (plugins).


Build principals — a repository the mesh trusts, with no secret to keep

A BuildPrincipal node is the third caller class on the registry surface, beside a signed-in user and a registered instance. Its subject is a GitHub repository's CI, it presents a short-lived OIDC token GitHub mints for the run, and there is no credential anywhere to store, rotate or leak. Introduced by #2483; the surrounding delivery design is Plugin build contractThe build principal.

Why it exists

Fetching an upstream's sealed publication needed an Azure OIDC identity whose federated credentials live in the Entra tenant — four of them, every one scoped to ref:refs/heads/main, none for pull_request, so a gate could not fetch on the one event it exists for (AADSTS700213, measured 2026-08-27 on MeshWeaver.SocialMedia#84 and MeshWeaver.Reinsurance#100). Nothing in the mesh recorded that those credentials existed, which repositories held one, or who authorised them.

That is the shape of the plaintext-provider-key incident: a security fact with no record a reader can point at. Here the rule IS a node — search nodeType:BuildPrincipal is the complete list of repositories this mesh trusts and exactly what each may do, and revoking one is a node write.

Azure OIDC federated credential Build principal
what is stored a subject rule, in Entra a subject rule, on a mesh node
who can see which repos may fetch whoever has tenant access search nodeType:BuildPrincipal
PR vs main one credential per event subject, per subject format one node; event_name is a claim it reads
secret in the repo none (already) none
verified by Azure the mesh, the way it already verifies mwa_ tokens

One verifier, two issuers, a trust node per issuer

InstanceRegistryAuthenticator.AuthenticateToken forks on the token's iss claim, read unverified, and that read only picks a verifier — it grants nothing, and every claim that matters (iss included) is re-read from the verified payload afterwards.

iss verified against resolves to
the registry itself SyncTokenSigningKeyService's HMAC material (HS256) a MeshWeaverInstance + its PluginGrant
https://token.actions.githubusercontent.com GitHub's published JWKS (RS256) a BuildPrincipal node

Neither leg ever honours a token's own alg. The HS256 leg accepts HS256 and nothing else; the RS256 leg accepts RS256 and nothing else, so alg: none and an RSA-public-key-as-HMAC-secret forgery are refused before a key is looked up at all.

🚨 A verified signature is not an authorization

Every workflow run on GitHub carries a token signed by these same keys. The signature establishes only which repository, on which event, asked. A verifier that checked it and stopped would authenticate the entire public GitHub's CI.

So the token is checked on five things — signature, iss, aud, validity window, and a non-empty repository — and then resolved to a node. No node ⇒ no caller ⇒ 401.

The node

It lives at Admin/_BuildPrincipal/{owner}--{repo}, in the Admin partition — the same place PluginGrant lives and for the same reason: the subject of an access decision must not be able to write the decision. The partition's own access control is the global-admin gate, not a second role check beside it that could drift.

{
  "$type": "BuildPrincipal",
  "repository": "Systemorph/MeshWeaver.SocialMedia",
  "repositoryId": "123456789",
  "events":    { "push": ["publish", "fetch"], "pull_request": ["fetch"] },
  "eventRefs": { "push": ["refs/heads/main"] },
  "scopes":    ["publish:socialmedia", "fetch:plugins"],
  "issuedBy": "…", "issuedAt": "2026-09-01T00:00:00Z"
}
field meaning
repository the repository claim it must match, exactly
repositoryId / repositoryOwnerId optional pins on GitHub's immutable numeric ids — a name can be renamed and re-registered, an id cannot
events which event_names may act, with which verbs. An event that is not a key here may do nothing
eventRefs optional per-event pin on the run's ref, so "push on main may publish" is expressible rather than merely intended. An event with no entry is not ref-constrained — a pull_request ref is refs/pull/<n>/merge and cannot be enumerated in advance
scopes verb:source, matched exactly on both halves. No wildcard: fetch:* is a scope for a source literally named *
issuedBy / issuedAt the audit trail the Entra credentials could not answer
lastSeen advisory, and nothing writes it yet — stamping it on the authentication path is a write per request, so an absent value means not recorded, never never used
requestedAction / isRevoked the stop, below

The scope split is the security tie. The identity that publishes a source is the identity that may fetch what it depends on, and it can do neither outside its scopes: SocialMedia's principal holds publish:socialmedia + fetch:plugins, so it can never publish as Plugins and never fetch a source it does not declare in requires. Both facts on one node.

Creating and revoking one

Both are ordinary node writes into the Admin partition — create the node, or write requestedAction: "Revoke" onto it. There is no bespoke request type and no service: stream.Update (and the create/patch tools that ride it) is the only mutation API, and the Admin partition is the gate.

🚨 The revoke is honoured immediately, not folded by a watcher. BuildPrincipal.IsActive reads requestedAction itself, so a principal stops authenticating on the very next request, on every replica, with nothing running in between. isRevoked: true is the equivalent permanent form for an admin who wants the record to read as revoked rather than as asked to be. A security stop that waits for a reactor is a security stop with a window.

Both subject formats, or a valid principal silently stops working

This fleet's federated credentials already carry two forms — repo:Systemorph/<repo>:ref:… and the immutable repo:Systemorph@<orgId>/<repo>@<repoId>:ref:…. Matching is therefore done on a normalized owner/name: an @<all-digits> suffix is stripped from either half, which is unambiguous because neither a GitHub login nor a repository name may contain @. Both forms also resolve to the same node path. Without this, the day GitHub moves an organisation onto immutable ids is the day every build principal in the fleet stops authenticating and nobody changed anything.

JWKS: cached, bounded, and fail-closed

GitHubOidcKeyService is a mesh-scoped singleton holding the key set on an instance field — never a static cache, which would outlive the mesh and bleed across tests and deployments. The HTTP read runs through IIoPool, and the promise is held so concurrent callers share one round trip; a failure nulls it, so the next caller starts a genuinely new attempt rather than replaying a latched OnError.

🚨 Undetermined is a third state, and it must not authenticate

A key set that cannot be read is not a denial and certainly not an admission. The read errors, the authenticator answers InstanceAuthResult.Unavailable, and the endpoint answers 503 + Retry-After — never the 401 a genuinely bad token gets. This is the same distinction the instance-key leg adopted in #2695 and the same rule core #2901 states generally: collapsing an unreachable check into a boolean is a defect. A build told "your identity is unknown" goes hunting for a credential that was never the problem.

The unknown-kid case is graded rather than flattened:

what happened answer
the key set was re-read and still does not hold the key 401 — GitHub does not publish it. A verdict
the refresh floor suppressed the re-read 503 — nothing was established; ask again past the floor

The node is read on every request — only the JWKS is cached

The instance leg caches its verdict for a minute because an install polls; a build asks a handful of times per run, so there is nothing to buy by caching it and a revocation window to lose. Reading the node every time is what makes requestedAction: Revoke take effect at once rather than when a verdict cache expires.

Where a build principal is admitted

The prebuilt-publication routes (/api/plugins/bundles/prebuilt/…) require fetch:<source>. The release-input routes (GET /api/plugins/roll-target, GET /api/plugins/is-updatable, and GET /api/plugins/combo) also admit a build explicitly granted verify:combo. POST /api/plugins/combo-verification accepts that build's off-portal verification result and records it through UpdatePolicyNodeType.RecordVerification. It accepts a ComboVerification, never a mesh path or a policy patch. The existing policy, including None, is preserved, and success is returned only after the node carries the recorded verdict.

This is a system identity for the build process, not a user or a global-admin token. The grant is local to the portal whose configured OIDC audience the token must match. Provision Admin/_BuildPrincipal/systemorph--meshweaver for Systemorph/MeshWeaver, binding its immutable repository and owner IDs. Permit verify only for workflow_run and workflow_dispatch, both restricted to refs/heads/main; grant only verify:combo. This gives pull requests and other repositories no compatibility-check rights. Revocation applies on the next request.

The verifier runs outside production; its grant does not install modules, apply an update, read arbitrary user content, edit access grants, or change update policy. Authentication outages still return 503, distinct from a refused credential.

A build is not an installation: it has no instance record, no plan and no PluginGrant, so every other bundle route — which decides per package against exactly those — keeps refusing it with the same 401 as before. The narrowing is expressed once, in the group filter, so a route added later is refused by default rather than by remembering to.

Pinned by GitHubBuildTokenTest, BuildPrincipalDecisionTest and BuildPrincipalAuthenticationTest (core, Memex.Portal.Shared.Test). Every refusal there starts from a token that WOULD be accepted and moves exactly one thing, so a passing assertion can only mean that one thing was checked.


Hierarchical access pattern

flowchart TB Global["Global Scope<br/>(empty namespace)"] --> Org["Space<br/>e.g., ACME"] Org --> Proj["Project<br/>e.g., ACME/ProjectX"] Proj --> Task["Task<br/>e.g., ACME/ProjectX/Task1"] style Global fill:#4caf50,color:#fff style Org fill:#2196f3,color:#fff style Proj fill:#ff9800,color:#fff style Task fill:#9c27b0,color:#fff

Examples — each is one AccessAssignment node whose MainNode equals the scope its path encodes:

Intent Node path MainNode Role
Space Editor: edit within ACME and its descendants ACME/_Access/Alice_Access ACME Editor
Project Viewer: read-only at ProjectX and below ACME/ProjectX/_Access/Bob_Access ACME/ProjectX Viewer
Platform admin (rare, named operator) Admin/_Access/Roland_Access Admin Admin

🚨 There is no "global admin ⇒ full access everywhere" shape. An Admin/_Access grant is scoped to the Admin partition; the root shape (_Access/{subject}_Access with an empty MainNode) is the data-superuser shape and must not be provisioned — see "The scope invariant" above. Copy-pasteable recipes: Granting Access.


Access Control UI

The Access Control layout area (AccessControlLayoutArea, Settings → Access Control) provides:

  1. Parent scope (read-only) — the AccessAssignment nodes inherited from the parent scope, rendered via the AccessAssignment Thumbnail area.
  2. Current scope (editable, admin-only) — the assignments at this node; role dropdown + Deny toggle bind directly to each assignment's node stream.
  3. Add row / Add Assignment dialog (admin-only) — subject picker + role select that creates the AccessAssignment node.
  4. Advanced — the partition policy (PartitionAccessPolicy) capping permissions for everyone at this scope and below.

The subject picker binds the canonical queries from AccessSubjectQueries (MeshWeaver.Mesh.Contract): users at the root namespace (served by the auth lookup mirror via UserNodeType's routing rule) plus groups in the scope's partition subtree. It loads the subject set once (capped at 500) and filters it in-memory, diacritic-insensitively (SearchText); beyond the cap, typed text falls back to the server-side search and the union is shown. Hand-rolled subject queries are forbidden — the legacy namespace:User / namespace:Group shapes target dropped schemas and silently return zero rows (issue #213). See Granting Access for the UI walkthrough and MCP recipes.


Partition access control

In multi-tenant PostgreSQL deployments, each organisation has its own schema (partition). Access to partitions is controlled by the partition_access table:

CREATE TABLE public.partition_access (
    user_id    TEXT NOT NULL,
    partition  TEXT NOT NULL,
    PRIMARY KEY (user_id, partition)
);

Populated automatically by rebuild_user_effective_permissions() in each partition's schema. When a user has any role in a partition, they receive a partition_access entry.

Cross-schema search enforces partition access at the SQL level — in the UNION branch PostgreSqlSqlGenerator.GenerateCrossSchemaSelectQuery emits per schema. The access control clause requires:

  1. Partition access — user must have a partition_access entry for the schema (always required)
  2. Node-level permission — user must have Read permission on the node's main_node path
-- Access control: partition_access is ALWAYS required, and the node-level
-- permission fold has no bypass.
WHERE partition_access_exists AND node_level_permission

🔒 There is no node-type public read (issue #953)

The predicate above used to carry a third term — public_read_node_type OR … — reading a per-schema node_type_permissions table. It was deleted, not connected. The short version:

Declare public read with a mechanism both read paths honour instead: a PartitionAccessPolicy _Policy node with PublicRead = true (issue #603 — projected as allow-Read rows for Public/Anonymous that participate in the prefix fold, so a deeper deny still wins), or a NodeTypeGate (issue #701) for a type that opens a short, explicitly listed set of surfaces on its own subtree.

Public policy grants and deeper read caps

PublicRead follows scope order. At each scope, the evaluator first applies that policy's cap to the inherited public grant, then adds the scope's own public grant. This preserves the PostgreSQL projection's existing order: policy caps are projected first, public grants replace them at the same prefix, and a more specific prefix wins when reading a descendant.

Policies on the path Read decision for a viewer without a role
Parent PublicRead = true; ordinary child Allow
Parent PublicRead = true; child Read = false Deny at the child and below
Same scope PublicRead = true and Read = false Allow
Parent public; child read cap; grandchild PublicRead = true Allow at the grandchild and below
Read = true alone No grant

BreaksInheritance resets inherited roles and their caps; it does not make a child's explicit read cap ineffective against a public ancestor. Role denies continue to remove roles, while PublicRead remains a separate grant. This rule does not change the additive NodeTypeGate contract or the separate Api capability cap.

Regression evidence (2026-09-11). On core baseline 3e731d947244b51b19f4c78b1114fc4273a9a840, PublicReadPolicyScopeTest executed 14 cases against a real monolith mesh without the fixture's default Public Admin grant. Four failed: anonymous and signed-in reads through a deeper read cap, each with and without BreaksInheritance. The other ten controls passed. The correction caps the accumulated public grant at each scope before applying that scope's own PublicRead. Fixtures use invented subjects and scopes; no production records are part of the test. After the correction, all 14 cases and the seven existing ApiTokenCapabilityFreshnessTest cases passed (21 total). Both baseline and corrected builds used dotnet build test/MeshWeaver.Graph.Test/MeshWeaver.Graph.Test.csproj -c Release -p:CIRun=true -warnaserror and finished with zero warnings and errors. The corrected test selection was FullyQualifiedName~PublicReadPolicyScopeTest|FullyQualifiedName~ApiTokenCapabilityFreshnessTest. Two existing RoutedApiTokenClampTest cases also passed. Against the same corrected core, the Plugins MeshWeaver.Security.Test project at 8ee8a192 built with the same strict flags and passed 33 existing cases selected by PartitionAccessPolicyTests, StaticNamespacePolicyTests, NodeTypeGateTests, AnonymousGateTests and UserPublicReadTest. These include a signed-in, unentitled viewer denied course content, public course surfaces and the entitled control.

The baseline evaluator SHA-256 was 472c97c6d7419145178cff0e693bde17cad2e9d17d35ecbfb96bf222e1af3219; this document's baseline SHA-256 was f67ad347c579f8bd483906fe7bcb4276d4fdd5f258491758154ab376ed35ad9b. The SQL comparison used MeshWeaver.Plugins commit 39bd2e7ae0c2e1574ce44b26f3ef0b9938d98c04, PostgreSqlSchemaInitializer.cs SHA-256 49515b4db3ffb27a5f6313577d239781447d10eb14087c1852a7b6d4162df026. Its bulk and per-user projection both apply same-prefix public grants after policy denies. These are source receipts; the core regression does not execute PostgreSQL.

AI tool call identity

When AI agents execute tool calls (Get, Update, Create, etc.) during thread streaming, the user's AsyncLocal access context doesn't flow through the AI framework's async tool invocation chain. All tools are wrapped with AccessContextAIFunction (a DelegatingAIFunction) that restores the user's identity from ThreadExecutionContext.UserAccessContext before each invocation.

This ensures tool calls run with the correct user identity for permission checks.

Satellite node permissions

🚨 Access is defined on the main node — satellites inherit it

A satellite has no access rights of its own. Permissions are defined on its MainNode, and whoever can Read the main node can Read every satellite under it. MeshNode.MainNode is a column on the node (the node for which the satellite exists); a main node has MainNode == Path.

This falls out of the scope walk for free: GetEffectivePermissions(path) (PermissionEvaluator) evaluates every scope from the root down through the partition and every ancestor to the path itself. A satellite/sub path such as {user}/_Thread/{threadId}/{messageId} therefore inherits the grants at {user} (the partition / main node) — the partition owner gets Read on the whole subtree without a per-satellite grant. To answer "can I read this thread / message?", you ask the security service for access on the path; you do NOT probe the leaf node's own hub.

Concretely:

For PostgreSQL the main node is reachable in a single query — the path itself determines schema (first segment) and table (satellite suffix, e.g. _Threadthreads), and every row carries its main_node column — but the read gate doesn't even need that: the scope walk over the path already covers it.

Required permission by node type

Satellite node types map to their required permission via GetPermissionForNodeType:

Node type Required permission
Thread, ThreadMessage Permission.Thread
Comment Permission.Comment
ApiToken, ModelProvider, MeshWeaverInstance Permission.Api
All others Permission.Create

(CreateNodePermissionAttribute.GetPermissionForNodeType, src/MeshWeaver.Mesh.Contract/CreateNodeRequest.cs — it feeds the CreateNodeRequest permission check, not just satellites.)


PostgreSQL integration

For PostgreSQL deployments, a denormalized user_effective_permissions table enables fast query-time permission checks. A trigger on mesh_nodes automatically rebuilds this table when AccessAssignment or GroupMembership nodes change.

-- Trigger fires on AccessAssignment/GroupMembership changes
CREATE TRIGGER mesh_node_access_changed
    AFTER INSERT OR UPDATE OR DELETE ON mesh_nodes
    FOR EACH ROW EXECUTE FUNCTION trg_mesh_node_access_changed();

The rebuild function:

  1. Reads AccessAssignment MeshNodes from mesh_nodes, unnesting each node's roles JSON array via jsonb_array_elements(content->'roles')
  2. Expands GroupMembership recursively (nested groups)
  3. Joins with Role definitions (built-in + custom Role MeshNodes)
  4. Produces per-user, per-permission rows in a shadow table
  5. Atomically swaps the shadow table into the live table

Node validation (INodeValidator)

The RlsNodeValidator (src/MeshWeaver.Graph/Security/RlsNodeValidator.cs) integrates with the mesh node CRUD pipeline. It declares four supported operations — Read as well as Create, Update and Delete:

public class RlsNodeValidator : INodeValidator, IOwnerEnforcedNodeValidator
{
    public IReadOnlyCollection<NodeOperation> SupportedOperations =>
        [NodeOperation.Read, NodeOperation.Create, NodeOperation.Update, NodeOperation.Delete];

    public IObservable<NodeValidationResult> Validate(NodeValidationContext context) { … }
}

Before it consults any permission, Validate applies two synchronous short-circuits:

  1. System bypassuserId == WellKnownUsers.System is always valid.
  2. Own-scope shortcut — a node whose MainNode equals the caller, or whose path is {userId} / {userId}/…, is valid unconditionally. Every user owns the partition named after their userId, so their own home never walks the access-rule chain.

Otherwise it checks the hub rule, then any registered per-type INodeTypeAccessRule, then hub.CheckPermission for the operation's required permission. RlsNodeValidator is registered by AddRowLevelSecurity() alongside PartitionWriteGuardValidator, OwnsPartitionProvisioningValidator and PartitionRootDeletionGuard — validators AND-compose, so a rejection by any one of them wins even when RLS would grant.

Node reads are validated through MeshCatalog.ValidateReadAsync, which runs the same validators; query listing is filtered separately, in SQL, by user_effective_permissions (see "PostgreSQL integration").


Hub identity and sanctioned dedicated identities

How messages authenticate

Every message in MeshWeaver carries an AccessContext that identifies the principal behind the operation. The UserServicePostPipeline decides the principal at post time:

  1. Explicit PostOptions.WithAccessContext(...) — if the caller pre-set the context (e.g. via accessService.ImpersonateAsSystem() or a sanctioned dedicated identity), use it. Do not overwrite.
  2. User in scope — if an authenticated user identity is set on AccessService.Context (or CircuitContext as fallback), attach it.
  3. Hub declared PostingIdentity.System (routing, persistence) — its own otherwise-unattributed posts are stamped system-security.
  4. Fail closed — otherwise, for a non-exempt message, the pipeline logs an Error and fails the delivery (d.Failed(...)), so an awaiting hub.Observe(...) gets a clean OnError. It does not deliver a null-context message. Exempt traffic ([SystemMessage], [CanBeIgnored], DeliveryFailure) is delivered with a null context. The "stamp hub-self as principal" fallback was removed 2026-05-21 because it silently masked the prod EventCalendar bug.

Per-message, per-delivery — the identity baton. The full propagation model is documented in AccessContextPropagation.md; read it before adding any new impersonation callsite.

Sanctioned dedicated identities — the only sanctioned override

When code legitimately runs as a component (cache hydrator, redistributor hub, onboarding writer) with no user behind it, do not stamp the running hub's accidental address as principal. Instead:

  1. Define a named, dedicated identity (cache/mesh-node-cache, portal/onboarding, protocol/sync-stream). The identity reflects the COMPONENT, not the hub.
  2. Grant that identity ONLY the specific operations it actually needs via per-NodeType access rules.
  3. Test the boundary — every misuse must yield UnauthorizedAccessException with a meaningful message.

This is the IsPortalIdentity pattern (User-node onboarding) generalised: every sanctioned bypass is a single, named, controlled seat — never a wildcard like "all sync/* get protocol perms". See AccessContextPropagation.md → Sanctioned exceptions for the define / grant / test contract.

// Pattern — define an internal constant
internal static class MeshNodeCacheIdentity
{
    internal const string Address = "cache/mesh-node-cache";
}

// Grant via per-NodeType access rule
config.AddAccessRule(
    [NodeOperation.Read],
    (_, userId) => userId == MeshNodeCacheIdentity.Address);

// Use at the point where the component acts
using (accessService.SwitchAccessContext(new AccessContext { ObjectId = MeshNodeCacheIdentity.Address }))
{
    // cache hydration runs here
}

// Test that misuse fails
[Fact]
public async Task MeshNodeCacheIdentity_CannotWrite()
{
    using (accessService.SwitchAccessContext(new AccessContext { ObjectId = "cache/mesh-node-cache" }))
    {
        // 🚨 Materialize, not act.Should().ThrowAsync() over an Rx ToTask bridge:
        // ToTask is forbidden repo-wide (2026-08-30), and folding OnError into a
        // value keeps the ORIGINAL exception type assertable. The write is cold, so
        // the assertion's Subscribe IS the write.
        var error = await meshService.CreateNode(someNode).Take(1).Materialize()
            .Should().Match(n => n.Kind == NotificationKind.OnError);
        error.Exception.Should().BeOfType<UnauthorizedAccessException>();
    }
}

Identity resolution in node operations

When HandleCreateNodeRequest (and its Update/Delete/CopyNodeRequest siblings) receives a message, it resolves the identity:

  1. If the request's CreatedBy / UpdatedBy / DeletedBy is explicitly set, use it.
  2. Otherwise fill from delivery.AccessContext.ObjectId.

So the principal that ran through the baton ends up on the stored row's CreatedBy. For user-driven writes this is the user's ObjectId; for sanctioned-identity-driven writes it is the dedicated address — auditable, visible in logs and queries.

Choosing the acting identity

When an operation needs an identity other than the calling user, pick from these — in order of preference:


Per-node-type access rules (INodeTypeAccessRule)

Some node types require custom access logic that differs from the standard AccessAssignment-based RLS check. For example, VUser nodes should only be creatable by portal hubs, regardless of AccessAssignment configuration.

The INodeTypeAccessRule interface lets node types replace the standard RLS check with custom logic:

public interface INodeTypeAccessRule      // src/MeshWeaver.Mesh.Contract/Services/INodeValidator.cs
{
    string NodeType { get; }
    IReadOnlyCollection<NodeOperation> SupportedOperations { get; }

    // Reactive — NOT Task<bool>. It composes into the data-layer chain without
    // awaiting a hub round-trip; a Task here would park the action block.
    IObservable<bool> HasAccess(NodeValidationContext context, string? userId);
}

When RlsNodeValidator encounters a node whose type has a registered INodeTypeAccessRule, it delegates to the rule instead of checking AccessAssignment permissions. The rule returns true to allow or false to deny.

How it works

flowchart TD A[RlsNodeValidator.ValidateAsync] --> B{Custom access rule<br/>for this NodeType?} B -->|Yes| C[INodeTypeAccessRule.HasAccessAsync] B -->|No| D[Standard RLS:<br/>Check AccessAssignment permissions] C -->|true| E[Valid] C -->|false| F[Unauthorized] D -->|Has permission| E D -->|No permission| F

Registering a custom access rule

Register via DI in your node type's configuration method:

public static TBuilder AddVUserType<TBuilder>(this TBuilder builder)
    where TBuilder : MeshBuilder
{
    builder.AddMeshNodes(CreateMeshNode());
    builder.ConfigureServices(services =>
    {
        services.AddSingleton<INodeTypeAccessRule, VUserAccessRule>();
        return services;
    });
    return builder;
}

Example: VUser access rule

The VUser node type uses a custom access rule that allows portal namespace hubs to create, read, and update VUser nodes:

private class VUserAccessRule : INodeTypeAccessRule
{
    public string NodeType => "VUser";

    public IReadOnlyCollection<NodeOperation> SupportedOperations =>
        [NodeOperation.Create, NodeOperation.Read, NodeOperation.Update];

    public IObservable<bool> HasAccess(NodeValidationContext context, string? userId)
        // Allow if the identity is in the portal namespace; deny all others.
        => Observable.Return(
            !string.IsNullOrEmpty(userId)
            && userId.StartsWith("portal/", StringComparison.OrdinalIgnoreCase));
}

For the common "predicate over (context, userId)" case you don't write a class at all — config.AddAccessRule(operations, (context, userId) => bool) (NodeAccessExtensions) collects the predicates into a NodeAccessRuleSet, and ToAccessRule(nodeType) wraps them in a FunctionalAccessRule that returns the first true. WithPublicRead() and WithSelfEdit() are built on it.

Key behaviors:

🚨 One question, ONE answer — every gate resolves the rule through NodeTypeAccessRuleSet

A rule only governs a node type if EVERY path that decides that node type's access consults it. Until #2913 one did not, and the gap was invisible from either side.

Both are correct in isolation and both are on the same request. The effective policy was their conjunction, so wherever a rule was more permissive than Permission.Delete the rule silently did not apply. SatelliteAccessRule maps a satellite's Delete to Permission.Update on its MainNode — creating a satellite is a modification of its main node, and so is removing it — and Role.Editor holds Update and not Delete. So an Editor could publish a satellite onto a node and then be refused when erasing it: "I can turn it on but not off", the exact state a revocable-consent feature exists to prevent, reachable by an ordinary Editor. It surfaced only when someone ran the writer (session presence, MeshWeaver.Plugins#1031), never by reading either file.

The fix is structural, not a second copy of the lookup: NodeTypeAccessRuleSet (src/MeshWeaver.Mesh.Contract/Services/NodeTypeAccessRuleSet.cs) is the one index, a mesh-scoped singleton registered in MeshBuilder, and both RlsNodeValidator and the delete pre-flight resolve their rule from it. Selection semantics live in one place: keyed by node type, case-insensitive, last registration wins, and a rule applies only when its SupportedOperations is empty or contains the operation.

What that widened, precisely — and what it did not:

Case Before After
Node type with a Delete-supporting rule that ALLOWS rule ∧ Permission.Delete the rule (this is the fix)
Node type with NO rule Permission.Delete Permission.Delete — unchanged, closed by default
Rule that DENIES refused refused (now at the pre-flight, so Unauthorized rather than ValidationFailed)
Rule that FAULTS or reaches no verdict n/a refused as Unavailablefail-closed
WellKnownUsers.System allowed via the fold's Permission.All allowed before any rule is consulted

The only types that gained anything are those whose rule chose a lighter demand: the SatelliteAccessRule family (Comment, TrackedChange, Kernel, Activity, UserActivity, ActivityLogSegment, and in MeshWeaver.Plugins Thread, ThreadMessage, TokenUsage, Portal), whose Delete is Update-on-MainNode by design. Space and Partition also carried Update-for-Delete in their rules — nobody could reach that, because the pre-flight's own Permission.Delete demand sat in front of it — so making the rule authoritative would have widened them. Both rules now say Permission.Delete for Delete explicitly, which is exactly the policy that was enforced before. A disagreement between two gates resolves to the STRICTER of the two unless there is a stated reason for the looser one — and for satellites the issue states it: publishing and erasing a satellite are the same act on its main node.

Fail-closed is not optional here. A rule reaches the same starve-able permission fold every other check does, so it can fault or complete without emitting. Neither is permission to proceed: both produce DeletePreflight.Unestablished, the delete is refused, and the response says NodeDeletionRejectionReason.Unavailable rather than Unauthorized — an availability failure, not a statement about the caller's rights (Unavailable, above). There is deliberately no .Catch(_ => true) on this path: the identical instinct on the security-fold twin is what made a group deny fail OPEN.

The caller-visible detail names the exception type and never its message. A rule is arbitrary code, and its exception text can carry an internal path, a connection string or another tenant's identifier — while the type is all a caller can act on ("retry, or ask an operator"). The full exception, message and stack, goes to the log where only an operator sees it.

🚨 …and the DELIVERY GATE is the third seam — it was left out, and that is #3061

The paragraph that used to stand here said the message-level gate was deliberately not routed through the rule: DeleteNodeRequest and ValidateDeleteRequest carry [RequiresPermission(Permission.Delete)], so on a route that passes through a per-node hub the AccessControlPipeline demanded Permission.Delete on the receiving hub's own path before the handler ran, and widening that was "a separate decision with a much larger blast radius". That decision has now been made, because the un-widened gate is a live defect — and it is the SAME defect #2913 fixed one seam earlier, which is precisely the shape a rule stated as "every path that decides this node type's access consults it" exists to prevent.

Measured, memex 2026-09-02 (#3061). A recursive delete of the orphan NodeType Edu/Course was refused with

Access denied: user 'rbuergi' lacks Delete permission on 'Edu/Course/_Activity/compile-…'

for all 72 of its _Activity satellites. Their registered SatelliteAccessRule says a satellite's Delete is Permission.Update on its MainNode — the very reasoning #2913 wrote down — but RequiresPermissionAttribute.GetPermissionChecks yields a RAW (hubPath, Permission) pair, the gate folded it with no rule in sight, and the gate runs first. So the one repair for a dangling NodeType was unavailable through any API.

The gate now consults the same authority, resolved through the same index (NodeTypeAccessRuleGate, src/MeshWeaver.Mesh.Contract/Services/NodeTypeAccessRuleGate.cs), which also owns the evaluation both seams share — its three terminals and the "detail names the exception TYPE, never its message" rule. Reading it is what tells you the two cannot drift again.

Where the reconsideration sits, and why there. It is reached ONLY from a definitive denial:

Fold outcome for (hubPath, Permission) What happens
Granted delivered — the rule is never consulted, so a hot SubscribeRequest reads no node
Denied re-decided through the node type's rule — the rule's answer is the gate's answer
Undetermined refused as Unavailable — "we could not check" is not a rule question

and four conditions each leave the denial exactly as it was: the check is not on this hub's own path; the permission names no operation a rule can decide about that node; nothing is served at that path; or no rule governs (node type, operation).

Permission.Create deliberately maps to no operation. A create names a node that does not exist yet and the gate evaluates on the PARENT's hub path, so the node type a lookup here would find is the parent's — and the parent's rule has no standing to decide a child's creation. RlsNodeValidator keys the Create rule off the node BEING CREATED, which only the handler can supply. Everything outside the CRUD four (Comment, Thread, Execute, Export, Compile, Api…) maps to nothing for the matching reason: INodeTypeAccessRule.SupportedOperations is expressed in NodeOperations, so a rule cannot have an opinion about them.

A node read that FAULTS answers Undetermined, never the original denial. Falling back to the denial would make a transient storage fault silently restore the pre-fix behaviour — the gate's own input deciding whether the gate ran, the shape AGENTS.md bans and MissingEvaluatorFailsClosedTests pins one level up. Undetermined is still fail-closed; it just stops claiming a verdict nobody reached.

Pinned by SatelliteDeliveryGateTest (test/MeshWeaver.Graph.Test), whose three cases are the three verdicts on one mesh: an Editor's satellite pre-flight is served, a Viewer's is refused Unauthorized (the rule DENIES — consulting a rule never means granting), and the same Editor at a plain Markdown child of the same node is still refused (no rule, nothing widened).

Pinned by DeleteHonoursNodeTypeAccessRuleTest (MeshWeaver.Plugins, src/MeshWeaver.Security.Test), whose four cases are the four rows of the table above — including the one that matters most, that an Editor still cannot delete a node whose type has no rule.

End-to-end: portal hub creating a VUser

sequenceDiagram participant Portal as Portal Hub<br/>(portal/mysite) participant Pipeline as UserServicePostPipeline participant Mesh as Mesh Hub participant RLS as RlsNodeValidator participant Rule as VUserAccessRule Portal->>Pipeline: Post(CreateNodeRequest, ImpersonateAsHub()) Pipeline->>Pipeline: AccessContext already set → skip Pipeline->>Mesh: Deliver message Mesh->>RLS: ValidateAsync(VUser node, Create) RLS->>RLS: NodeType="VUser" → custom rule exists RLS->>Rule: HasAccessAsync(userId="portal/mysite") Rule-->>RLS: true (portal namespace) RLS-->>Mesh: Valid Mesh-->>Portal: CreateNodeResponse(Success)

Message-level permission enforcement

RequiresPermissionAttribute

Message types declare the permission they require via [RequiresPermission]. When a message arrives at a node hub with the AccessControlPipeline enabled, the pipeline checks whether the sender has the required permission on the hub's path. If denied, a DeliveryFailure with ErrorType.Unauthorized is returned.

// Simple: single permission on the hub path
[RequiresPermission(Permission.Read)]
public record SubscribeRequest(...);

[RequiresPermission(Permission.Create)]
public record CreateNodeRequest(...);

[RequiresPermission(Permission.Update)]
public record DataChangeRequest(...);

Built-in annotated messages

Message Required permission
SubscribeRequest Read
GetDataRequest Read
CreateNodeRequest Create
ImportNodesRequest Create
ImportContentRequest Create
stream.Update (PatchDataRequest) Update
DataChangeRequest Update
UndoActivityRequest Update
RollbackNodeRequest Update
UpdateUnifiedReferenceRequest Update
DeleteNodeRequest Delete
DeleteContentRequest Delete
DeleteUnifiedReferenceRequest Delete
MoveNodeRequest Custom (see below)

Custom permission checks

For messages that need non-trivial authorisation logic, inherit from RequiresPermissionAttribute and override GetPermissionChecks. The method receives the IMessageDelivery and the hub path, and returns multiple (path, permission) pairs — all must pass.

// MoveNodeRequest needs Delete on source + Create on target
[MoveNodePermission]
public record MoveNodeRequest(string SourcePath, string TargetPath);

public class MoveNodePermissionAttribute() : RequiresPermissionAttribute(Permission.Update)
{
    public override IEnumerable<(string Path, Permission Permission)> GetPermissionChecks(
        IMessageDelivery delivery, string hubPath)
    {
        if (delivery.Message is MoveNodeRequest move)
        {
            yield return (GetNamespace(move.SourcePath), Permission.Delete);
            yield return (GetNamespace(move.TargetPath), Permission.Create);
        }
        else
        {
            yield return (hubPath, Permission.Update);
        }
    }

    private static string GetNamespace(string path)
    {
        var lastSlash = path.LastIndexOf('/');
        return lastSlash > 0 ? path[..lastSlash] : path;
    }
}

Extending with custom permissions

🚨 Bits 1 through 1024 are ALL taken by built-in permissionsRead 1, Create 2, Update 4, Delete 8, Comment 16, Execute 32, Thread 64, Api 128, Export 256, Sync 512, Compile 1024. A custom permission must start above those, and the value must be picked by reading the enum, not guessed:

// ❌ WRONG — 64 is Permission.Thread and 128 is Permission.Api. A message
//    declared [RequiresPermission((Permission)64)] silently demands Thread.
const Permission Approve = (Permission)64;

// ✅ Next free bit above the built-ins.
const Permission Approve = (Permission)2048;

[RequiresPermission((Permission)2048)]
public record ApproveDocumentRequest(string Path);

Before adding one, check src/MeshWeaver.Messaging.Contract/Security/Permission.cs for the highest bit currently in use — and note that a new bit is not part of Permission.All, so no built-in role grants it until you add it to one explicitly.

AccessControlPipeline

The AccessControlPipeline is a delivery pipeline step registered by AddRowLevelSecurity() on all default node hubs. It runs before the message handler and:

  1. Reads the RequiresPermissionAttribute from the message type (cached per type)
  2. Calls GetPermissionChecks() to get the list of (path, permission) pairs
  3. Checks each pair against PermissionEvaluator.HasPermission(...) (returns IObservable<bool> — composed into the pipeline, never awaited)
  4. If any check fails → sends DeliveryFailure(ErrorType.Unauthorized) back to sender

Messages without [RequiresPermission] pass through unchecked. System messages (PingRequest, InitializeHubRequest, etc.) are not annotated and are always allowed.


Configuration

Enable row-level security in your mesh configuration:

var builder = new MeshBuilder()
    .UseMonolithMesh()
    .AddFileSystemPersistence(dataPath)
    .AddRowLevelSecurity();

AddRowLevelSecurity() registers:

PermissionEvaluator itself is not a registered service — it is a static class.


Best practices

  1. Start with hierarchy — assign roles at the organisational level and let inheritance handle descendants.
  2. Use deny sparingly — deny overrides only the specific role, not all permissions.
  3. Anonymous for unauthenticated access — configure the Anonymous user with Viewer role on namespaces that should be visible without login.
  4. Public for authenticated baseline — configure the Public user with Viewer role on namespaces that all logged-in users should access.
  5. No manual cachingPermissionEvaluator is a static algorithm whose state lives in the process-wide IMeshNodeStreamCache under $security-access:{partition} / $security-policy:{partition} (plus their root-scope twins) / $security-roles / $security-memberships. Those queries are kept live by their own change feeds; there is no separate TTL cache to invalidate.
  6. Fail closed — no roles assigned means no permissions (Permission.None).
  7. Audit via MeshNodes — AccessAssignment nodes provide a clear audit trail of who has access to what.
  8. Use ImpersonateAsHub() for hub operations — when a hub needs to perform operations as itself, use PostOptions.ImpersonateAsHub() instead of setting identity on AccessService directly.
  9. Custom access rules for special node types — use INodeTypeAccessRule when a node type needs access logic that differs from standard AccessAssignment-based RLS (e.g., namespace-based identity checks).
Reconnecting…
The server was updated. Reloading the page to pick up the latest version.