π¨ TL;DR β
public.mesh_nodesis empty by design. Every mesh node lives in a per-partition schema (acme.mesh_nodes,user.mesh_nodes,dav.mesh_nodes, β¦). Thepublicschema holds only infrastructure tables (partition_access,searchable_schemas,user_effective_permissions, β¦). Queryingpublic.mesh_nodesalways 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.
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:
- If a path-segment match is found β use the mapped table.
- If no match but a
nodeTypeis provided βResolveTableByNodeType(nodeType). - Otherwise β
mesh_nodes.
Implementation: PostgreSqlStorageAdapter.ResolveTable (MeshWeaver.Plugins/src/MeshWeaver.Hosting.PostgreSql/PostgreSqlStorageAdapter.cs).
β Footgun β wrong segment, wrong table. If you write an
AccessAssignmentwhose namespace does not end in_Access(e.g. you writeAdmin/Groups/G1instead ofAdmin/Groups/_Access/G1), the row lands inmesh_nodesinstead ofaccess. Theaccess_changedtrigger will never fire, andrebuild_user_effective_permissionswill 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:
- write + path-read both hit
mesh_nodes(the selection actually persists β contrast the dead_ThreadTemplate/nodeType:Threadapproach, which split writeβthreadsfrom readβmesh_nodesand silently lost the selection); - the nodes are auto-hidden from search;
- never reuse
_ThreadTemplateβ it matched the_Threadβthreadssatellite prefix bynodeTypeand is the cautionary tale.
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_schemasis 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 β seePostgreSqlSchemaInitializer.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.EnumerateFanOutAsynchas 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:
- It never did anything. The table's only writer,
SyncNodeTypePermissionsAsync, hung offInitializePostgreSqlSchemaAsync, which had zero callers β the migration container callsPostgreSqlSchemaInitializer.InitializeAsyncdirectly. Every deployment's copy of the table was empty, so the term was a constantfalse. Removing it is a provable no-op. - Wiring it up would have been a breach, not a fix. ~24 node types declared public read, among them
ThreadandThreadMessage(every user's private conversations),Markdown,CodeandDocument(the bulk of all content), andCourse,Module,Exercise,ExerciseAttempt(paid course content and learners' own submissions). - The shape was wrong even for a safe type list. The term was an unconditional
ORin front of gate 2, so it short-circuited the longest-prefix fold β which is exactly where the store/course paywall's DENY rows live. A grant that cannot be denied is not a grant, it is a hole. - It had no counterpart in the evaluator.
PermissionEvaluatorhas no node-type-keyed term, so SQL listing and exact reads would have diverged β the failure mode the evaluator's own comments record from memex-cloud 2026-07-19.
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_nodesis empty. Every "I queried Postgres and the row isn't there" report has come from looking inpublic.*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_nodesbypassing the storage adapter, verify the path contains the satellite suffix. A missing suffix lands the row inmesh_nodesand silently prevents the corresponding triggers β especiallyaccess_changedβ from firing.
π¨
rebuild_user_effective_permissionsis per partition. It runs againstSET LOCAL search_path = {schema}, publicand updates only that schema'suser_effective_permissionspluspublic.partition_access. There is no global rebuild β call it once per partition.
π¨ Both
partition_accessanduser_effective_permissionsare required. A user with row-level permissions but nopartition_accessrow sees nothing in the partition. A user withpartition_accessbut no row-level permissions sees nothing β the oldpublic_readnode-type escape hatch was deleted (issue #953); there is no node-type public read. Public read is declared with aPartitionAccessPolicy_Policynode (PublicRead = true) or aNodeTypeGate, both of which materialise rows that participate in the prefix fold. Forgetting either table produces silent denials.
π¨
access_changedfalls back to a full rebuild whenaccessObjectis null. Always populateaccessObjectinAccessAssignmentcontent. A missing value triggersrebuild_user_effective_permissionsover the entire partition instead of the fast per-user variant, locking the shadow table.
π¨ The
namespacecolumn keeps the partition prefix β do NOT strip it. Inside{partition}.mesh_nodes,namespacestores the full namespace including the partition prefix (e.g.rbuergi/ApiToken, not bareApiToken). The generatedpathcolumn isnamespace || '/' || idβ the partition is not auto-prepended. Stripping the prefix to "make namespaces relative" silently breaks dashboard listings (namespace:rbuergi/ApiToken nodeType:ApiToken),ApiTokenIndex.tokenPathlookups,MainNodereferences, and anything else that builds full-path queries. Exception: the user-identity row and a small set of root-level Markdown nodes legitimately live atnamespace='', id=X(full path = justX) β those are special, not the rule.
π¨ Address a row by
pathβ NEVER by splitting a path into(namespace, id). An id may contain/: everyLanguageModelnode's id is the provider's wire id (z-ai/glm-5.3,anthropic/claude-opus-5). SplittingProvider/OpenRouter/z-ai/glm-5.2at the last slash looks fornamespace='Provider/OpenRouter/z-ai', id='glm-5.2'while the stored row isnamespace='Provider/OpenRouter', id='z-ai/glm-5.2'β no row matches, so the read answers null and the DELETE removes nothing, surfacing asNodeDeletionRejectionReason.NodeNotFoundfor a nodegetresolves in the same breath (issue #2212 β no model node could be deleted through the API or MCP at all). Thepathcolumn isGENERATED ALWAYS AS (CASE WHEN namespace = '' THEN id ELSE namespace || '/' || id END) STOREDonmesh_nodesand 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/Deleteand 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'NAMEDATALENtruncates silently, so a char count would admit an unroutable name). It is applied by the Postgres provider's ownEnsurePartitionProvisioned(refuses with anArgumentExceptionnaming 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 inMeshExtensions, and byOwnsPartitionProvisioningValidatoron everySpacecreate._-prefixed names are refused like any other: a global satellite (_Access) gets its schema from a REGISTEREDPartitionDefinition(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.PartitionNameRefusalTestpins the rule with the exact names of the incident below.If a database still lists schemas like
login?error=auth_failed,search?q=β¦orsomeone@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 beforeUserContextMiddlewarerefused email-shaped object ids) and are dropped by repair migrationV51_DropInvalidPartitionSchemaswhen the migration runs at head. The ACAprod-memexdeployment 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 runningMemex.Portal.Distributeddoes NOT propagate to in-memory workspace streams reliably β symptoms: MCPgetreturns "not found" while search hits the new path, API token 401s after the 5-minuteValidationCacheexpires, recompile-on-edit doesn't fire. Migrations should run viaMemex.Database.Migration(Repair vN block) before the portal starts. If you must SQL-edit a live portal, restartMemex.Portal.Distributedafterwards (Aspire respawns it automatically). For namespace/path rewrites, preferMoveNodeRequestover 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 |