Mod permissions, channels and blob storage
Three host services that Enki B specifies for every mod
(Enki B (docs/superpowers/specs/2026-09-27-enki-b-mod-platform-contract-design.md)
§4, §7, §8). Their native Rust APIs and host plumbing exist on both hosts; the
guest-facing WIT interfaces (manifold:perms/permissions,
manifold:net/mod-channel, manifold:storage/blob-store) are published after
the contract foundation gate, and mods’ declarations reach the host as
ModDeclarations fields from contract M1b. Until then a declaration is
registered through the native APIs below.
Permission nodes
A player has a level (Player < Admin) and a set of permission nodes,
dotted names <namespace>.<name> such as build.edit. A limit is a valued
node, an i64 per player, named <namespace>.<thing>.max.
- One catalogue.
PermissionCatalogue(crates/manifold-server-state/src/permissions/catalogue.rs) holds every node and limit. The platform programs register theirs at boot (permissions::platform(crates/manifold-server-state/src/permissions/platform.rs)): Enki’sbuild.*andworld.*, Pygmalion’sanimation.*and Talos’stalos_observe::nodes::NODES(crates/manifold-server-state/src/talos_observe/nodes.rs), each with its own defaults. A mod’s declarations join throughregister_mod, which enforces the namespace rule: a mod declares only under its own id and references only platform names and its own. - One seam. Every check goes through
anim_contract::player_has_node(crates/manifold-server-state/src/anim_contract.rs), a bit test in the player’s resolved set inConnectionPermission(crates/manifold-server-state/src/connection_permission.rs). Admin and the local host hold every node; a node the catalogue does not know is Admin’s only; an unknown connection holds nothing. - Resolution. A connection’s set is resolved at accept and on change, never
per check, from its level, local-host status and, for a verified platform
username only,
permissions.ron. A node is held when its default isplayeror the file grants it, unless the file denies it;denynever removes anything from Admin or the local host. Limits take the local host’smax, else the declared default for the level. - The client’s copy. Each connection receives its own level, nodes and
limits in
S2C::PermissionsUpdate(client state:OwnPermissions(crates/manifold-client-state/src/mod_channel.rs)), once its grants are installed and after every change. It is for display and UI gating only. - Mod UIs. The reserved
manifold:ui-actor-permissionbundle entry carries the actor’s level, then the nodes it holds and the limit values within the mod’s scope (UiActorPermissionWire(crates/manifold-mod-types/src/ui_actor.rs)). A guest that decodes only the level still reads it.
permissions.ron
An optional file beside ops.ron in the server data directory. ops.ron keeps
meaning Admin.
// permissions.ron — platform usernames, matched only when verified
(
players: {
"Carol": (grant: ["enki.use"]),
"Dave": (grant: ["enki.use"], deny: ["build.edit"]),
},
)
A missing file grants nothing; a malformed one grants nothing and logs a
warning. /perm show <user>, /perm grant <user> <node> and
/perm revoke <user> <node> (Admin) read and rewrite it atomically and update
connected players at once. Tiers, default overrides and per-player limit values
are not applied yet (#249); a file that uses them loads its players section
and warns.
Platform namespaces
The namespaces platform programs own are reserved mod ids
(reserved.rs (crates/manifold-modloader/src/reserved.rs)), listed with
their owners in the platform-namespaces table (docs/architecture/extension-contracts.md).
Mod message channel
A mod declares named channels between its halves
(ChannelDecl (crates/manifold-mod-types/src/channel.rs)): a direction,
a size cap, a token bucket and an optional requires node. Every message
rides one reliable transport channel, CH_MOD (7), as C2S::Mod or
S2C::Mod, so a large mod payload never delays engine events.
- Handles. Both sides build the same
ModChannelRegistry(crates/manifold-host-core/src/mod_channel.rs): mods sorted by id, then each mod’s channels in declaration order. A guest numbers its channels by declaration position. - Server delivery. The inbound phase stages each message
(
mod_channel(crates/manifold-server-state/src/mod_channel.rs) in server state); before the tick’s guest systems,deliver_server_inbounddrops and counts messages on an unknown handle, against the channel’s direction, over its size cap, from a sender without itsrequiresnode, or over the sender’s token bucket. The rest reach the mod’s inbox in arrival order with the sender’s player, level, and nodes and limits within the mod’s scope, captured at delivery. - Limits. A declaration is clamped by the server’s
ChannelLimits; an inline message never exceeds 1 MiB. Buckets refill per server tick in integer milli-tokens, identically on both hosts. - Replies (
server_send_to,server_broadcast) queueS2C::Modon the connection at once. The client checks its sends too (client_send), so a client learns it is rate limited instead of being dropped.
Only reliable inline messages are served. Transfers come later; an
unreliable-latest channel is served reliably, capped at the datagram size.
Blob storage
ModBlobStore (crates/manifold-server-state/src/mod_blobs.rs) keeps
per-mod blobs in the world save, in the world scope (per mod) and the
world-player scope (per mod and durable player id). Both hosts store them in
one redb table, mod_blobs_v1
(table helpers (crates/manifold-saves-types/src/mod_blobs.rs)), in the
same database as the chunks: the native save store and the browser’s
OPFS-backed RedbOpfsSaveStore.
- Keyed by the host. Every operation names the mod id the host authenticated; a guest never states it.
- Deferred results. Each call returns a request id; results drain through
take_completionson both executors. - Saved with the world. A write is staged and reaches the table in the world’s next save transaction, so blobs never disagree on disk with the world they describe. Reads see staged changes first; a failed save stages its rows again.
- Quotas. 1 GiB per mod for
worldand 256 MiB per mod per player forworld-player(noworld-playerquota in browser singleplayer); a write over quota fails and changes nothing. - Paths are 1 to 8
/-separated segments, at most 256 bytes, with no empty,.or..segment.
Regression coverage
- Catalogue, resolution and namespace rule: unit tests in
manifold-server-state(permissions,connection_permission);permissions.ronand/perminmanifold-server. - Channel registry and token buckets:
manifold-host-coremod_channeltests; blob store:manifold-server-statemod_blobs; the redb table:manifold-saves-typesmod_blobs(the helpers both hosts use). - On a real server over the local transport:
mod_platform_e2e.rs(crates/manifold-server/tests/mod_platform_e2e.rs) (ordering, every drop reason, sender context, replies, permission updates, blobs across a restart).
The browser runs the same server-state code in its worker; no browser test exercises these services end to end yet.