No Static Collections β Ever
The rule in one sentence: any
staticfield that holds a collection or cache is forbidden. Every cache and every repository must be an instance owned by the mesh, so its lifetime is bounded and it can never bleed across tests, users, or partitions.
This is an absolute architectural invariant β equal in weight to "nothing async ever" and "GetMeshNodeStream().Update() is the only mutation API". It is checked by NoStaticCollectionsTest, which reflects over the MeshWeaver.*.dll files and fails on any static mutable-collection field not recorded in its Allowed map, each entry carrying a one-word CONST/MEMO/CACHE/PROC reason. π¨ That test no longer lives in this repository β it travelled to MeshWeaver.Plugins with the emigrated mesh suites (#2276), which keeps TWO copies of it (src/MeshWeaver.PathResolution.Test/ and src/Memex.Hosts.Test/, each with its own Allowed map). So a core change that adds a static collection, or that invalidates an existing entry's stated reason, reddens in the PLUGINS repo and not here, and updating the allowlist is a change to that repo. That map is the single source of truth for permitted static state; this document explains the categories and shows you what to do instead.
π¨ The check is a test, not a compiler rule, and its reach is the test project's own output directory β i.e. the transitive closure of MeshWeaver.PathResolution.Test's project references, not all 76 MeshWeaver.* projects. A static cache added in an assembly that closure does not pull in will not be caught. Treat the rule as binding everywhere and the test as a backstop over part of the tree; if you add a static field in a peripheral project, the absence of a red test is not evidence you are allowed to.
Static fields outlive every mesh instance and bleed across tests and partitions; instance singletons registered in MeshBuilder are isolated to their own mesh lifetime.
Why static state is dangerous here
The mesh is an actor system that is stood up and torn down many times in a single process β once per test run, and once per tenant or partition in production. A static field lives for the lifetime of the process, not the mesh, so it persists across those boundaries in two harmful ways:
- Cross-test bleed. One test's writes are visible to the next test in the same process. The tell-tale symptom is a
Clear()method added "for test isolation" β which papers over the structural problem without fixing it and makes parallel test execution unsafe. - Cross-partition bleed in prod. A process-wide cache is shared by every tenant in the same worker. One partition's writes become visible in another partition's reads.
An instance owned by the correct scope has neither problem: it is created when that scope starts, disposed when it ends, and is invisible to every other concurrent scope.
Scoping caches correctly
Choose the narrowest scope that owns the state, then hold the backing collection as an instance field on a type with that lifetime.
| Scope | Register / own it as | Backing field | Example |
|---|---|---|---|
| Mesh | AddSingleton<T>() in MeshBuilder.ConfigureServices |
instance ConcurrentDictionary / IMemoryCache |
a node-type repository registered per hub container |
| Per-hub | service in a node-type's HubConfiguration, or hub-owned object |
instance field | CachingStorageAdapter._snapshot; SearchHub._pending |
| Per-session / component | per-render object the framework creates | instance field | LayoutAreaHost.TryMarkEditStateInitialized |
| App | ASP.NET AddSingleton middleware |
instance field | UserContextMiddleware._loginDedup |
| Per execution context | AsyncLocal<T> (a single value, not a collection) |
AsyncLocal<T> |
XUnitFileOutputRegistry (active test's output helper) |
The canonical mesh-scoped repository
// β FORBIDDEN β process-wide, survives mesh disposal, bleeds across tests
public static class NodeTypeRegistry
{
private static readonly ConcurrentDictionary<string, MeshNode> Nodes = new();
public static void Clear() => Nodes.Clear(); // β "for test isolation" = the tell
}
// β
REQUIRED β instance repo, dies with the mesh, no Clear() needed
public sealed class NodeTypeRepository
{
private readonly ConcurrentDictionary<string, MeshNode> nodes = new(); // instance field
public void Register(MeshNode node) => nodes[node.Path] = node;
public bool TryGet(string path, out MeshNode? node) => nodes.TryGetValue(path, out node);
}
// Register once in MeshBuilder β lifetime IS the mesh
builder.ConfigureServices(s => s.AddSingleton<NodeTypeRepository>());
For caches that need TTL or eviction, hold an IMemoryCache as the instance field and implement IDisposable so the cache is disposed with its owner.
What static state IS allowed
The build guard classifies permitted static fields into four buckets. Everything else must become an instance.
CONST β Immutable lookup tables
static readonly collections initialized once and never written at runtime: media-type maps, reserved-word sets, built-in role tables, SQL-keyword lists, parser character sets. If nothing calls Add, []=, Remove, or Clear on it after construction, it is a constant, not a cache β it is safe.
π¨ "Never written" is about the ELEMENTS, not just the collection. A write-once list of objects that carry their own mutable, lazily-materialized state is a process-wide cache, and CONST does not cover it. This exemption was applied twice to
MeshNodeCompilationService._referencesβ astatic readonly IReadOnlyList<MetadataReference>β on the grounds that the list is never mutated. It isn't; but eachPortableExecutableReferenceowns lazily memory-mapped,IDisposablemetadata and Roslyn hangs its derived assembly/symbol tables off that same instance, so the field is a mutable process-wide cache instatic readonlyclothing. It survived two reviews and is the shared state #890 came down to.Ask "could two meshes observe each other through an element of this?" β not "does anything call
Add". RoslynMetadataReferences in particular are now rejected by their own guard inNoStaticCollectionsTest, whatever the declared collection type, because the declared-type check could not see anIReadOnlyList<>.The field itself is still there, listed as a
CACHEdebt with its issue number rather than silently exempt β removing it gives every mesh its own reference set, which measurably costs ~15 MiB per compiling mesh, and whether that buys anything is decided by #890's emit canary. Landing the detector does not require paying for the fix, and an allowlist entry with a reason is what turns "nobody noticed" into "we know, and here is the open question".
MEMO β Pure memoization on process-global keys
Process-global memoization keyed by a process-global identity (Type, MethodInfo, or deterministic content), where the cached value is a pure function of the key so cross-mesh sharing is always correct.
Current MEMO caches:
GenericCaches.TypeCaches/MethodCachesβ keyed byType/MethodInfoAccessControlPipeline.AttributeCacheβ keyed byTypeMessageHubConfiguration._systemMessageCacheβ keyed byTypeMarkdownExtensions.PipelineCacheβ keyed by contentDynamicTypeGenerator.TypeCacheβ keyed by property schemaDefaultImplementationOfInterfacesExtensions.NonVirtualInvocationThunksβ keyed byMethodInfoKernelScriptReferences.Materializedβ keyed by absolute assembly path (see the caveat below)
π¨ A MEMO's key space is a claim, and it has to be ENFORCED β
Materializedis why (#4003). This entry was allowlisted on the words "bounded by the set of assemblies on disk β¦ it can pin neither meshes nor collectible NodeType contexts". The second half was true of the OBJECT GRAPH and stayed true: noType, noAssemblyLoadContextis retained, which is exactly why nothing here ever appeared as a pinned ALC. The first half was false, because the key space was not bounded: every NodeType recompile emits into a brand-new{nodeName}_{ticks}_{guid}/directory that is never reused, the kernel's cell-surface seam feeds those paths straight in, andMetadataReference.CreateFromFilememory-maps the PE β so each recompile left one more native metadata mapping alive for the life of the process, surviving both the ALC'sUnload()and the file's deletion.The lesson generalises to every entry on this list: "pure by key" is only half the test; the other half is whether the KEY SPACE is finite, and a memo that cannot answer that is a leak with a reason attached.
Materializednow refuses admission to any file belonging to a collectible load context β the reference is still produced, but unmemoized, so its lifetime is the kernel session that asked (which already holds that generation's ALC lease).IsMemoized(path)exists so a control test can assert the bound from outside instead of trusting the prose β per PATH, not as an entry count, because the count moves with whatever else the shard has loaded. Full derivation: NodeType Compilation β "A THIRD root holds a generation".
PROC β Process-global resource registrations
A registry tied to a process-global resource where per-mesh scoping makes no sense and there is no possibility of cross-partition bleed.
Current PROC cache: KernelExecutor._probingDirs, which backs the single process-wide AssemblyLoadContext.Default.Resolving hook.
TESTPERF β Cross-method fixture sharing within a test class
MonolithMeshTestBase._sharedProviders: a service-provider cache keyed by test-class Type, so it isolates across classes and only shares within a single class's methods (which xUnit serializes). IClassFixture is the idiomatic long-term form.
Adding a new static collection? If it genuinely fits one of the four buckets above, add it to the allow-list with the one-word bucket label. If it does not fit, make it an instance β that is always the right answer.
Token validation: reads, not caches
API-token validation deliberately does not cache. Each token lives as a node at {userId}/Token/{tokenHash} under the user partition, mirrored into the auth schema by the per-partition trigger (alongside User, Group, Role, and VUser). Validation is a single live query:
workspace.GetQuery("auth:tokenByHash:{hash}", "nodeType:ApiToken content.tokenHash:{hash} limit:1")
Because the query is backed by IMeshNodeStreamCache, it is always live. When a token is revoked (the IsRevoked field flips on the node), the change propagates immediately β there is no cache to hold a stale answer and no cross-hub invalidation to coordinate. The same shape is used by OnboardingMiddleware.FindUserByEmail (nodeType:User content.email:{email}).