MeshWeaver enforces strict data access patterns to ensure security (RLS validation), consistency, and traceability. Application code must never use IMeshStorage or IMeshCatalog directly β these are internal infrastructure interfaces.
There are three access patterns, each covering a distinct class of operation:
| Operation | Pattern | Interface / Type |
|---|---|---|
| Read nodes | Query | IMeshQuery |
| Read/write one node by path | Stream | hub.GetMeshNodeStream(path) / workspace.GetMeshNodeStream(path) |
| Create node | Service | meshService.CreateNode(node).Subscribe(...) |
| Update node | Service | meshService.UpdateNode(node).Subscribe(...) (routes through GetMeshNodeStream(path).Update) |
| Create-or-update (upsert) | Request | hub.Observe<CreateOrUpdateNodeResponse>(new CreateOrUpdateNodeRequest(node)).Subscribe(...) |
| Delete node | Service | meshService.DeleteNode(path).Subscribe(...) |
| Write as system / hub | AccessService | using (accessService.ImpersonateAsSystem()) { β¦ } / ImpersonateAsHub(hub) |
| Move node | Message | hub.Observe(new MoveNodeRequest(src, dst)).Subscribe(...) |
| Update typed-entity data | Message | DataChangeRequest β hub (EntityStore collections β see CRUD) |
π¨ One mesh node by path β
GetMeshNodeStream, neverGetRemoteStream<MeshNode>The single canonical API for reading or writing one mesh node by path is
hub.GetMeshNodeStream(path)/workspace.GetMeshNodeStream(path)(extension methods inMeshWeaver.Mesh.Contract). It routes every reader and writer through the sharedIMeshNodeStreamCacheβ one process-wide upstream per path, so writes are visible to all readers. Read by subscribing to the handle (IObservable<MeshNode>,Contentalready typed); write via.Update(current => current with { β¦ }).Subscribe(...)(cold observable β the write only runs onSubscribe).
workspace.GetRemoteStream<MeshNode, β¦>/GetRemoteStream<MeshNode>(addr)throwsInvalidOperationExceptionβ the single-node remote reduce does not converge (divergent mirror streams, writes invisible to readers), soWorkspace.ThrowIfMeshNoderefuses it at the call site rather than letting a latent bug ship. The only sanctioned callers are the cache's own upstream and the MeshNode reduce-callback plumbing, which use the internalGetRemoteStreamUncheckedoverload.
Three access patterns funnel through the security layer before reaching storage β direct storage access bypasses RLS and auditing.
1. Reads β IMeshQuery
All read operations go through the query surface β IMeshQuery.Query<T>(MeshQueryRequest) (or the
same-shaped IMeshService.Query<T>(...) extensions) β which uses a GitHub-style query syntax to
filter, search, and enumerate nodes. It is reactive: the first emission carries the full initial
result set (ChangeType == Initial), later emissions carry Added / Updated / Removed deltas.
π¨ There is no
QueryAsyncon the interface.QueryAsyncsurvives only as a test-only bridge (MeshWeaver.Fixture/IMeshQueryTestExtensions) that materialises the Initial emission asIAsyncEnumerable. Production code composesIObservable<T>end-to-end β see Asynchronous Calls. And for a live collection, don't call the query surface directly at all: useworkspace.GetQuery(id, queriesβ¦)(Synced Mesh Node Queries).
// Resolve from the hub's service provider
var query = hub.ServiceProvider.GetRequiredService<IMeshQuery>();
// List direct children of a path β project to the fields you consume.
query.Query<MeshNode>(MeshQueryRequest.FromQuery(
"path:org/Acme scope:children select:path,name,nodeType"))
.Take(1)
.Subscribe(change => { /* change.Items */ },
ex => logger.LogWarning(ex, "query failed"));
// Search by text within a namespace, capped.
query.Query<MeshNode>(MeshQueryRequest.FromQuery(
"namespace:org nodeType:Team Report limit:10"))
.Subscribe(change => { /* change.Items */ },
ex => logger.LogWarning(ex, "query failed"));
Reserved qualifiers (QueryParser.ReservedQualifiers) β everything else in the string is either
a property filter (field:value, with >/</>=/<=, * wildcards, A|B|C alternation and
- negation) or free text (routed to vector search on Postgres when an embedding provider is
registered):
| Token | Meaning |
|---|---|
path:<path> |
Anchor path (combine with scope: to walk) |
namespace:<path> |
Anchor namespace; implies scope:children unless scope: is given |
scope:<exact\|children\|descendants\|subtree\|ancestors\|selfAndAncestors\|hierarchy\|nextLevel> |
How to walk from the anchor |
select:<fields> |
Column projection β content is loaded only if named |
sort:<field>[-desc] Β· limit:<n> |
Ordering and cap |
source:<activity\|accessed> Β· context:<ctx> Β· is:main |
Result source / visibility filters |
Filter by node type with the ordinary property filter nodeType:Team β there is no type: or
parent: qualifier. Tokens compose freely: "path:org scope:descendants nodeType:Team Alpha"
matches Team nodes under org scored against the text "Alpha".
2. Creates, Updates, and Deletes β IMeshService (reactive)
Node lifecycle operations route through IMeshService and return IObservable<MeshNode> β cold observables that run on Subscribe. All operations travel through the message bus (CreateNodeRequest etc.) so that security validators (INodeValidator, RLS) are enforced for every write. The caller''s identity is captured automatically from AccessService.Context at call time.
var meshService = hub.ServiceProvider.GetRequiredService<IMeshService>();
// Create β identity auto-captured from the current user
var node = MeshNode.FromPath("org/Acme/NewTeam") with
{
Name = "New Team",
NodeType = "Team"
};
meshService.CreateNode(node)
.Subscribe(
created => logger.LogInformation("Created {Path}", created.Path),
ex => logger.LogWarning(ex, "Create failed"));
// Chained create β update: compose with SelectMany β never nest Subscribes
meshService.CreateNode(node)
.SelectMany(created => meshService.UpdateNode(created with { Name = "Renamed Team" }))
.Subscribe(_ => { }, ex => logger.LogWarning(ex, "create+update failed"));
// Delete β removes the node and all its descendants, bottom to top
meshService.DeleteNode("org/Acme/OldTeam").Subscribe(...);
// Create-or-update (upsert) β single verb when the caller has the full target shape
hub.Observe<CreateOrUpdateNodeResponse>(new CreateOrUpdateNodeRequest(node))
.Subscribe(resp => { /* resp.Message.WasCreated */ }, ex => ...);
Behaviour summary:
CreateNodeβ runsINodeValidator, sets state to Active.UpdateNodeβ validates; routes through the canonicalGetMeshNodeStream(path).Updatewrite path on the owning hub.DeleteNodeβ removes the node and all descendants (bottom to top).CreateOrUpdateNodeRequestβ upsert; checks existence on the handler side and dispatches create or merge-patch update (see CQRS).- Identity is auto-captured from
AccessServiceand carried across.Subscribe()boundaries β see AccessContextPropagation.
Writing as system / hub β explicit impersonation
By default every write runs under the calling user''s identity. Infrastructure code with no human in the loop (cache hydration, seeds, sync heartbeats) opts in explicitly:
// System identity β Permission.All, well-known "system-security" principal
using (accessService.ImpersonateAsSystem())
{
meshService.CreateNode(systemNode).Subscribe(...);
}
// Hub identity β stamps the hub''s address as principal
using (accessService.ImpersonateAsHub(hub))
{
meshService.CreateNode(hubOwnedNode).Subscribe(...);
}
PostPipeline fails closed when no context is set β a write with neither a user nor an explicit impersonation is rejected, never silently stamped. Full reference: AccessContextPropagation.
Reading as hub / system β the same explicit impersonation
Reads scope through the same AccessService impersonation: wrap the subscription, and every query / stream opened inside the scope runs under that identity for RLS filtering β useful when infrastructure code reads before a user context is established.
// Query with hub identity β RLS filters against the hub's own permissions
using (accessService.ImpersonateAsHub(hub))
{
meshService.Query<MeshNode>(MeshQueryRequest.FromQuery($"path:{nodePath}"))
.Subscribe(result => { /* β¦ */ }, ex => logger.LogWarning(ex, "query failed"));
}
Example: aggregating across business-unit sub-hubs (FutuRe)
The FutuRe/Analysis group hub needs to read data from two business-unit sub-hubs: FutuRe/AsiaRe/Analysis and FutuRe/EuropeRe/Analysis. Each sub-hub has its own RLS scope, so the parent hub must be granted explicit read access via AccessAssignment nodes, then query using its own identity.
Step 1 β grant read access in each sub-hub (see samples/Graph/Data/FutuRe/AsiaRe/Analysis/_Access/FutuRe_Analysis_Access.json):
{
"id": "FutuRe_Analysis_Access",
"namespace": "FutuRe/AsiaRe/Analysis/_Access",
"name": "FutuRe/Analysis Node Access",
"nodeType": "AccessAssignment",
"content": {
"$type": "AccessAssignment",
"accessObject": "FutuRe/Analysis",
"displayName": "Group Analysis Hub",
"roles": [
{ "role": "Viewer" }
]
}
}
Apply the same pattern to FutuRe/EuropeRe/Analysis/_Access/FutuRe_Analysis_Access.json.
π¨
Viewer, and only a role the mesh actually defines. This snippet used to read"Reader"β a role that exists nowhere (Role.csdefinesAdmin,Editor,Viewer,Commenter,PlatformAdmin), so it resolved to no permissions at all whileAccessAssignmentGuard's fail-closed allowlist counted it as WRITE and refused the grant on every GitSynced partition. Read-only meansViewerorCommenter; anything else is an ownership claim.
Step 2 β query with the hub's identity:
// The parent hub can now read descendants of each sub-hub
using (accessService.ImpersonateAsHub(hub))
{
meshService.Query<MeshNode>(
MeshQueryRequest.FromQuery("path:FutuRe/AsiaRe/Analysis scope:descendants"))
.Subscribe(change => { /* aggregate sub-hub data */ },
ex => logger.LogWarning(ex, "aggregation failed"));
}
Without the impersonation scope, the query runs under the end user's identity β which may lack access to all sub-hubs. With it, the parent hub reads using its own permissions and can always aggregate across business units.
3. Moves and Data Changes β Message-Based
A small number of operations are driven by request messages posted directly to the hub rather than through IMeshService.
Moving a node
hub.Observe(new MoveNodeRequest("org/Acme/OldPath", "org/Acme/NewPath"))
.Subscribe(
response =>
{
if (response.Message is MoveNodeResponse { Node: not null } moveResult)
{
// Move succeeded
}
},
ex => logger.LogWarning(ex, "Move failed"));
Updating data collections (typed entities)
// Replace a set of entities in an EntityStore collection β see /Doc/DataMesh/CRUD
hub.Post(new DataChangeRequest
{
Updates = [updatedEntity]
});
Updating a MeshNode's content
// The ONE mutation API β cold observable, the trailing Subscribe runs the write.
workspace.GetMeshNodeStream(path).Update(node => node with { Content = updated })
.Subscribe(_ => { }, ex => logger.LogWarning(ex, "update failed"));
(PatchDataChangeRequest is the internal stream-protocol message the framework ships for you β never post it from application code.)
Defining Message Types
Request/response messages must implement IRequest<TResponse> so the messaging framework can route responses correctly and hub.Observe can infer the response type.
// Request β implements IRequest<TResponse> for type-safe hub.Observe
public record MoveNodeRequest(string SourcePath, string TargetPath)
: IRequest<MoveNodeResponse>;
// Response
public record MoveNodeResponse
{
public bool Success { get; init; }
public MeshNode? Node { get; init; }
public string? Error { get; init; }
}
Sending and observing:
// TResponse is inferred from IRequest<MoveNodeResponse>
hub.Observe(new MoveNodeRequest("old/path", "new/path"),
o => o.WithTarget(targetAddress))
.Subscribe(
response => { if (response.Message.Success) { ... } },
ex => logger.LogWarning(ex, "Move failed"));
// Tests bridge to Task via MonolithMeshTestBase.AwaitResponseAsync(request, ...).
Registering the handler:
// π¨ Synchronous handler β never `async`, never `await` (it deadlocks the hub's
// single-threaded action block). Long work composes reactively and posts the
// response from inside the Subscribe callback; the handler returns immediately.
config.WithHandler<MoveNodeRequest>((hub, delivery) =>
{
// process the request...
hub.Post(new MoveNodeResponse { Success = true, Node = moved },
o => o.ResponseFor(delivery));
return delivery.Processed();
});
Registering types for serialization:
config.TypeRegistry.WithType(typeof(MoveNodeRequest), nameof(MoveNodeRequest));
config.TypeRegistry.WithType(typeof(MoveNodeResponse), nameof(MoveNodeResponse));
Why Are the Internal Interfaces Off-Limits?
IMeshStorage and IMeshCatalog are internal to infrastructure assemblies. Using them directly from application code:
- Bypasses RLS validation β security policies are enforced at the message handler layer, not in storage.
- Breaks traceability β message-based operations are logged and auditable; direct storage calls are not.
- Couples to storage details β backends (Cosmos, PostgreSQL, file system) are an implementation detail and may change.
- Skips business rules β
INodeValidatorruns at the handler level on every create, update, move, and delete.
Only infrastructure assemblies (MeshWeaver.Hosting, MeshWeaver.Hosting.Orleans, etc.) have InternalsVisibleTo access to these interfaces.