🚨 TL;DR β€” public.mesh_nodes is empty by design. Every mesh node lives in a per-partition schema (acme.mesh_nodes, user.mesh_nodes, dav.mesh_nodes, …). The public schema holds only infrastructure tables (partition_access, searchable_schemas, user_effective_permissions, …). Querying public.mesh_nodes always returns zero rows, no matter how full the mesh is.

This page is the deep companion to Partitioned Persistence. That doc covers the routing layer that sits in front of the database; this one covers what is actually in the database.

Mesh Node Write e.g. ACME/Project/Foo/_Access/G1 Path Segment Routing first segment β†’ schema | path suffix β†’ table acme schema path starts with ACME/… user schema path starts with User/… dav schema path starts with DAV/… Satellite Tables access ← _Access/ suffix threads ← _Thread/_ThreadMessage annotations ← _Comment/_Approval + activities / code / mesh_nodes Satellite Tables access ← _Access/ suffix threads ← _Thread/_ThreadMessage annotations ← _Comment/_Approval + activities / code / mesh_nodes public schema β€” Two-Gate Access Check Gate 1: partition_access user has ANY access to partition? Gate 2: user_effective_permissions longest-prefix match on node path +

Per-partition schema layout: path segment selects the Postgres schema, path suffix routes to the satellite table, and reads require passing both public-schema gates.


Per-partition schema model

The first path segment of any mesh node (lowercased and SQL-sanitised) becomes the Postgres schema name:

Path Schema
ACME/Project/Foo acme
User/rbuergi/Notes user
DAV/Underwriting/AlpenLloyd2026 dav
123-org/Foo _123_org
org.with.dots/Foo org_with_dots

The sanitiser is PostgreSqlPartitionedStoreFactory.SanitizeSchemaName β€” it lowercases, replaces non-alphanumeric characters with _, and prefixes leading digits with _.

The following schema names are excluded from partition discovery because they are infrastructure or satellite-only:

admin, portal, kernel,
_access, _address_, _graph, _settings, _tracking, _thread, _source, _test,
login, markdown, onboarding, welcome, settings, storage,
mesh, thread, agent, partition, organization, vuser,
public, information_schema, pg_catalog, pg_toast,
*_versions

The canonical discovery query β€” used by the migration script and every "which partitions exist?" sweep (there is no DiscoverPartitionsAsync API; the router does not enumerate schemas):

SELECT schema_name FROM information_schema.schemata s
WHERE EXISTS (
    SELECT 1 FROM information_schema.tables t
    WHERE t.table_schema = s.schema_name AND t.table_name = 'mesh_nodes')
  AND s.schema_name NOT IN ('public', 'information_schema', 'pg_catalog', 'pg_toast')
  AND s.schema_name NOT LIKE '%\_versions' ESCAPE '\';

Implementation: MeshNodeEmbeddingBackfill / SchemaInitialization in MeshWeaver.Plugins/src/Memex.Database.Migration/Migrations/ use exactly this shape.


Per-schema table layout

Every partition schema contains a consistent set of tables. The primary table holds general-purpose entities; the satellite tables exist to separate high-volume or functionally distinct data into dedicated stores with purpose-built triggers.

Table Purpose Routes for
mesh_nodes Primary entities All "main" node types
activities Satellite Activity
user_activities Satellite UserActivity (high-volume time-series)
threads Satellite Thread, ThreadMessage
access Satellite AccessAssignment
code Satellite Code (under Source/ and Test/ namespaces)
annotations Satellite Comment, Approval, TrackedChange (legacy β€” no longer written)
partition_objects Internal Non-mesh partition data
change_logs Bundled activity log (internal)
user_activity Per-user access patterns (internal)

Partitions with Versioned = true also get a sibling {schema}_versions schema:

Table Purpose
mesh_node_history Append-only history of every mesh_nodes write

The mesh DDL plus all triggers and stored procedures are emitted by PostgreSqlSchemaInitializer (MeshWeaver.Plugins/src/MeshWeaver.Hosting.PostgreSql/PostgreSqlSchemaInitializer.cs).


NodeType β†’ table routing

Writes do not pick their destination table from the C# NodeType string alone β€” they pick based on the path itself, by longest-segment match. The defaults live in SatelliteTableMapping.Defaults (src/MeshWeaver.Mesh.Contract/SatelliteTableMapping.cs) β€” a static readonly immutable list, i.e. a constant lookup, not a mutable static dictionary. (The old static PartitionDefinition.StandardTableMappings / NodeTypeToSuffix dictionaries are deleted.)

Segment Table NodeTypes that resolve to it
_Activity activities Activity
_UserActivity user_activities UserActivity
_Thread threads Thread, ThreadComposer
_ThreadMessage threads ThreadMessage
_Access access AccessAssignment
_Tracking annotations TrackedChange (legacy, read-only)
_Approval annotations Approval
_Comment annotations Comment
_Notification notifications Notification
Source code (none β€” path-matched only)
Test code (none β€” path-matched only)

The set is configurable, not hardcoded: per host via PostgreSqlStorageOptions.SatelliteTables, and per namespace via PartitionDefinition.TableMappings / NodeTypeTableMappings (populated from PartitionDefinition.DefaultSegmentTableMappings() / DefaultNodeTypeTableMappings()).

PartitionDefinition.ResolveTable(path) scans the path for the longest matching segment. The fallback chain is:

  1. If a path-segment match is found β†’ use the mapped table.
  2. If no match but a nodeType is provided β†’ ResolveTableByNodeType(nodeType).
  3. Otherwise β†’ mesh_nodes.

Implementation: PostgreSqlStorageAdapter.ResolveTable (MeshWeaver.Plugins/src/MeshWeaver.Hosting.PostgreSql/PostgreSqlStorageAdapter.cs).

⚠ Footgun β€” wrong segment, wrong table. If you write an AccessAssignment whose namespace does not end in _Access (e.g. you write Admin/Groups/G1 instead of Admin/Groups/_Access/G1), the row lands in mesh_nodes instead of access. The access_changed trigger will never fire, and rebuild_user_effective_permissions will not see the assignment. This was the bug behind Repair v1 (MeshWeaver.Plugins/src/Memex.Database.Migration/Program.cs:133).

The _ prefix means hidden, not satellite

A leading-underscore path segment is a hidden ("dotfile") namespace β€” like a Unix dot-folder. It is decoupled from satellite-table routing: ONLY the registered suffixes above route to a satellite table. A new _-prefixed segment that isn't in the mapping (e.g. _Memex) falls through to mesh_nodes for both the write and the path-based read β€” no satellite mismatch, no extra table needed.

What the _ prefix does buy you, everywhere, is visibility hiding: any node whose path contains a _-prefixed segment is excluded from the search context (MeshNodeVisibility.IsHiddenPath / IsExcludedFromContext, consulted by every query backend β€” Postgres, Cosmos, storage-adapter, static). This is the same search-context exclusion that MeshNode.ExcludeFromContext provides per-type, but applied by path convention so framework/default state never has to opt out individually.

_Memex β€” per-user / global Memex defaults

_Memex is the namespace for Memex defaults and global Memex data β€” framework-owned state that isn't user content. Per-user defaults live at {user}/_Memex/…; the canonical example is the side-panel chat composer's singleton {user}/_Memex/ChatInput (draft text + selected harness/agent/model). Because _Memex is a dotfile namespace that is not a registered satellite suffix:


public schema β€” infrastructure only

The public schema plays a single, well-defined role: it holds the cross-partition infrastructure that the storage adapter and permission system need at query time. No mesh nodes ever live here.

Table Purpose
partition_access Binary "user X has any access to partition P" gate. PK (user_id, partition). Populated by per-schema rebuild_user_effective_permissions.
searchable_schemas Schemas the cross-schema UNION fans out over. Repopulated on every migration run.
node_type_permissions πŸͺ¦ Legacy, always empty, read by nothing (issue #953). Kept for one release only so a rolling deploy's older replicas don't fault on the table name; a follow-up migration drops it. See "Why there is no node-type public read" below.
user_effective_permissions and _shadow Denormalised cache of every (user, path-prefix, permission) tuple. The shadow is rebuilt then atomically swapped (PostgreSqlSchemaInitializer.cs:542).
change_logs Partition-level change feed.

Triggers and the permission-rebuild chain

Two independent trigger chains keep permissions and audit history consistent.

Permission chain β€” fires on every change to {schema}.access:

INSERT/UPDATE/DELETE on {schema}.access
        β”‚
        β–Ό
trg_access_changed()           ← extracts accessObject from new/old content
        β”‚
        β”œβ”€β”€ if accessObject IS NOT NULL:
        β”‚       SELECT {schema}.rebuild_user_permissions_for(accessObject)
        β”‚       (per-user fast path, won't lock other users)
        β”‚
        └── else:
                SELECT {schema}.rebuild_user_effective_permissions()
                (full rebuild: locks shadow table for the whole partition)
                Repopulates partition_access for every user that ends up
                with Read at any path in this partition.

History and notification chain β€” fires on every change to {schema}.mesh_nodes:

INSERT/UPDATE on {schema}.mesh_nodes
        β”‚
        β–Ό
trg_mesh_node_to_history()     ← cross-schema INSERT into {schema}_versions.mesh_node_history
        β”‚
        β–Ό (separate trigger, conditional on subscriber)
notify_mesh_node_changes()     ← LISTEN/NOTIFY for live subscribers

Source: PostgreSqlSchemaInitializer.cs:717 (access), :796 (notify), :827 (history).


Two-gate access model

Reading from a partition schema requires passing both gates in sequence. A row that passes one but not the other is invisible to the caller.

Gate 1 β€” partition gate

EXISTS (SELECT 1 FROM public.partition_access WHERE user_id = $me AND partition = 'acme')

No row here means the user cannot read anything in the partition, regardless of any row-level grants.

Gate 2 β€” node gate A matching row in {schema}.user_effective_permissions with the longest-prefix match against the node's path, folded per subject and OR'd across subjects. There is no bypass of this gate.

Cross-schema search iterates searchable_schemas, applies both gates per schema, and returns only rows where both pass. The runtime builds that UNION in C# β€” PostgreSqlSqlGenerator.GenerateCrossSchemaSelectQuery, one branch per schema carrying the per-schema user_effective_permissions clause.

🚨 public.search_across_schemas is no longer called by the portal. The plpgsql function still exists (an older replica mid-rollout still calls it, so it is not dropped), and it enforces the same two gates β€” see PostgreSqlSchemaInitializer.cs:74. But it backed a second fan-out shape whose only distinctive behaviour was clipping an unlimited query at 50 rows, and no runtime caller ever reached it: PostgreSqlPartitionedMeshQuery.EnumerateFanOutAsync has only ever taken the table-name overload. That overload was deleted in #2048 β€” two independent access-control implementations for one logical query, one of them unexercised, is where a security fix lands on the wrong copy.

Why there is no node-type public read

Both gates used to carry a third term: EXISTS (SELECT 1 FROM {schema}.node_type_permissions WHERE node_type = n.node_type AND public_read), OR'd in front of gate 2. It was removed in issue #953 rather than wired up, and it must not come back in that shape:

To make content publicly readable, use a mechanism both read paths honour:

Need Mechanism
A whole partition/subtree world-readable PartitionAccessPolicy _Policy node with PublicRead = true (issue #603). Projected into user_effective_permissions as allow-Read rows for Public/Anonymous, so it participates in the longest-prefix fold β€” a deeper deny still wins.
A type that opens a short list of surfaces on its own subtree (storefront cover, course landing page) NodeTypeGate via ConfigureNodeTypeAccess(a => a.WithGate(...)) (issue #701).

Which schemas are searchable (and the catalog-partition rule)

searchable_schemas is (re)discovered by PostgreSqlCrossSchemaQueryProvider.SyncSearchableSchemasAsync: every schema that has a mesh_nodes table, minus the ExcludedSchemas denylist (the auth access-object mirror β€” to avoid double-surfacing; admin/portal/kernel; _-prefixed satellite/global schemas; and a set of legacy reserved route words).

🚨 A public catalog partition MUST NOT be in ExcludedSchemas. The platform AI catalogs β€” agent, skill, model, _provider, harness, command β€” are real publicRead partitions whose nodes are listed by the per-partition registry fan-out: a single multi-namespace query of the form namespace:{user}/Agent|{space}/Agent|Agent nodeType:Agent (see AgentPickerProjection). That query is unscoped (a namespace IN (...) membership filter, no concrete first path segment), so it routes through the cross-schema fan-out, which only visits schemas in searchable_schemas. If a catalog schema is excluded, the fan-out silently skips it and the registry comes back empty (the chat agent/model/skill picker shows nothing). A single-namespace query (namespace:Agent) masks the bug: it is scoped β€” it resolves the one schema directly via the registered-partition cache, bypassing searchable_schemas. The agent picker was empty on prod (2026-06-20) for exactly this reason: "agent" was a stale entry in ExcludedSchemas from before the per-partition agent-registry migration, so skill/model worked but agent did not.


Versioning schemas

Partitions with Versioned = true (the default for content partitions) get a sibling {schema}_versions schema containing only mesh_node_history. The primary key is (namespace, id, version); a changed_by column records authorship. The cross-schema mesh_node_copy_to_history trigger writes a new row on every primary-table change. Direct INSERTs into mesh_node_history during a migration bypass the trigger and preserve audit fidelity.


Repair migrations

MeshWeaver.Plugins/src/Memex.Database.Migration/Program.cs runs idempotent schema initialisation on every start (PostgreSqlSchemaInitializer.InitializeAsync) and versioned data repairs that execute once per database. The DB version is stored in admin.mesh_nodes at (namespace='', id='db_version').

Version Fix
v1 Move misrouted AccessAssignment rows from mesh_nodes to access; add /_Access to namespace
v2 Re-run schema init per partition + populate partition_access
v3 Drop rogue schemas accidentally created from path segments
v4 Upgrade user self-assignments from Viewer to Admin
v5 Ensure every User node has an Admin self-assignment + rebuild permissions
v6 Fix search_across_schemas to enforce partition_access
v7 Deploy per-user permission-rebuild trigger function
v8 Fix ThreadMessage.MainNode to point at the thread's content node, not the thread path
v9 Rename _Source/_Test namespace segments to Source/Test
v10 … see below

The table above is the early history only. Migrations now live as one file per version in MeshWeaver.Plugins/src/Memex.Database.Migration/Migrations/ (V01_… … V51_… at the time of writing) β€” read that directory, not this table, for the current head version and for what each step does. Notable later ones: V10_PerUserPartitions, V27_RenameUserSchemaToAuthAndMirrorApiTokens, V28_RenameOrganizationToSpace, V38_DropLegacyProviderSchema, V45_AddNodeAuthorshipColumns, V50_RescopePlatformAdminGrants, V51_DropInvalidPartitionSchemas.

🚨 Fresh databases fast-forward. MigrationRunner skips the legacy user-schema repair chain (V05/V10/V14/V15/V17/V18/V20/V22/V25/V27/V31 β€” all reference the long-gone user schema) when SchemaInitialization.DetectFreshDbAsync reports no CONTENT partition schemas. Framework schemas (admin/auth/system_*) are excluded from that count so they can never make a fresh DB look non-fresh.


🚨 Footguns β€” read once, never trip again

🚨 public.mesh_nodes is empty. Every "I queried Postgres and the row isn't there" report has come from looking in public.* instead of the partition schema. Run the discovery query above first.

🚨 Satellite tables are routed by path segment, not nodeType. If you bulk-insert via SQL or write directly to mesh_nodes bypassing the storage adapter, verify the path contains the satellite suffix. A missing suffix lands the row in mesh_nodes and silently prevents the corresponding triggers β€” especially access_changed β€” from firing.

🚨 rebuild_user_effective_permissions is per partition. It runs against SET LOCAL search_path = {schema}, public and updates only that schema's user_effective_permissions plus public.partition_access. There is no global rebuild β€” call it once per partition.

🚨 Both partition_access and user_effective_permissions are required. A user with row-level permissions but no partition_access row sees nothing in the partition. A user with partition_access but no row-level permissions sees nothing β€” the old public_read node-type escape hatch was deleted (issue #953); there is no node-type public read. Public read is declared with a PartitionAccessPolicy _Policy node (PublicRead = true) or a NodeTypeGate, both of which materialise rows that participate in the prefix fold. Forgetting either table produces silent denials.

🚨 access_changed falls back to a full rebuild when accessObject is null. Always populate accessObject in AccessAssignment content. A missing value triggers rebuild_user_effective_permissions over the entire partition instead of the fast per-user variant, locking the shadow table.

🚨 The namespace column keeps the partition prefix β€” do NOT strip it. Inside {partition}.mesh_nodes, namespace stores the full namespace including the partition prefix (e.g. rbuergi/ApiToken, not bare ApiToken). The generated path column is namespace || '/' || id β€” the partition is not auto-prepended. Stripping the prefix to "make namespaces relative" silently breaks dashboard listings (namespace:rbuergi/ApiToken nodeType:ApiToken), ApiTokenIndex.tokenPath lookups, MainNode references, and anything else that builds full-path queries. Exception: the user-identity row and a small set of root-level Markdown nodes legitimately live at namespace='', id=X (full path = just X) β€” those are special, not the rule.

🚨 Address a row by path β€” NEVER by splitting a path into (namespace, id). An id may contain /: every LanguageModel node's id is the provider's wire id (z-ai/glm-5.3, anthropic/claude-opus-5). Splitting Provider/OpenRouter/z-ai/glm-5.2 at the last slash looks for namespace='Provider/OpenRouter/z-ai', id='glm-5.2' while the stored row is namespace='Provider/OpenRouter', id='z-ai/glm-5.2' β€” no row matches, so the read answers null and the DELETE removes nothing, surfacing as NodeDeletionRejectionReason.NodeNotFound for a node get resolves in the same breath (issue #2212 β€” no model node could be deleted through the API or MCP at all). The path column is GENERATED ALWAYS AS (CASE WHEN namespace = '' THEN id ELSE namespace || '/' || id END) STORED on mesh_nodes and every satellite table, and it is indexed β€” so matching on it is both the only correct decomposition-free addressing and the cheapest. Read / ReadMany / Exists / Delete and the version-history reads all do; keep it that way in every adapter (Snowflake maintains the same column as a real column on write).

🚨 A partition name IS a schema name β€” ONE rule, enforced at every seam that can turn a string into a schema. The rule is PartitionDefinition.IsValidPartitionSegment (#714): start with a letter or digit, then only letters, digits, ., -, _, at most 63 bytes of UTF-8 (Postgres' NAMEDATALEN truncates silently, so a char count would admit an unroutable name). It is applied by the Postgres provider's own EnsurePartitionProvisioned (refuses with an ArgumentException naming the id, BEFORE the promise cache β€” never a cached silent no-op) and its path router (an unroutable first segment gets no schema), by the partition bootstrap in MeshExtensions, and by OwnsPartitionProvisioningValidator on every Space create. _-prefixed names are refused like any other: a global satellite (_Access) gets its schema from a REGISTERED PartitionDefinition (system_access), never from its name. Never lowercase or sanitize a name by hand to get it past the rule β€” a name that fails it is a caller bug, and refusing it loudly is the point. PartitionNameRefusalTest pins the rule with the exact names of the incident below.

If a database still lists schemas like login?error=auth_failed, search?q=… or someone@example.com (#2900 Β§3), read the image version before hunting for a creator. Those are the shapes the rule exists to refuse, and on current code no path can provision them; they were produced by a pre-#714 image (request URLs routed as mesh paths; an email used as a partition key before UserContextMiddleware refused email-shaped object ids) and are dropped by repair migration V51_DropInvalidPartitionSchemas when the migration runs at head. The ACA prod-memex deployment showed exactly this on 2026-09-01: its migration container runs the 2026-06-03 image at db_version 32, so it has never executed V51 β€” the junk is a consequence of the stale image, not of a live creator. Rolling that deployment to a current image cleans it; dropping the schemas by hand is an ops decision and is deliberately NOT what this rule does.

🚨 Direct SQL UPDATE on a running portal leaves stale workspace caches. BEGIN; UPDATE {partition}.mesh_nodes …; COMMIT; against a running Memex.Portal.Distributed does NOT propagate to in-memory workspace streams reliably β€” symptoms: MCP get returns "not found" while search hits the new path, API token 401s after the 5-minute ValidationCache expires, recompile-on-edit doesn't fire. Migrations should run via Memex.Database.Migration (Repair vN block) before the portal starts. If you must SQL-edit a live portal, restart Memex.Portal.Distributed afterwards (Aspire respawns it automatically). For namespace/path rewrites, prefer MoveNodeRequest over raw SQL β€” it goes through the hub and updates the workspace stream correctly.


Key source files

File Contents
MeshWeaver.Plugins/src/MeshWeaver.Hosting.PostgreSql/PostgreSqlSchemaInitializer.cs DDL, stored procedures, triggers (~2 500 lines)
MeshWeaver.Plugins/src/MeshWeaver.Hosting.PostgreSql/PostgreSqlPathRoutingAdapter.cs First-segment β†’ schema/table routing (no probe, no cache)
MeshWeaver.Plugins/src/MeshWeaver.Hosting.PostgreSql/PostgreSqlPartitionStorageProvider.cs EnsurePartitionProvisioned β€” the ONE schema-creation entry point
MeshWeaver.Plugins/src/MeshWeaver.Hosting.PostgreSql/PostgreSqlStorageAdapter.cs Write-side table resolution (ResolveTable)
src/MeshWeaver.Mesh.Contract/SatelliteTableMapping.cs The configurable satellite defaults
src/MeshWeaver.Mesh.Contract/PartitionDefinition.cs TableMappings / NodeTypeTableMappings and ResolveTable
MeshWeaver.Plugins/src/Memex.Database.Migration/Migrations/ One file per versioned migration (V01_… … V51_…)
MeshWeaver.Plugins/src/Memex.Database.Migration/Program.cs Migration harness + idempotent schema init + embedding backfills
Reconnecting…
The server was updated. Reloading the page to pick up the latest version.