Rules & Visibility
View and alter rules — path patterns, owner gating, serverChecked conditions, and how visibility slicing keeps hidden state on the server.
Every path in a realm’s state answers two questions, decided by rules: who may see it (view) and who may change it directly (alter). Rules are declared in the definition, so they run identically as client-side prediction and as server-side authority.
rules: (r) => [ // Everyone sees config; only server code writes it. r.path("config.*").view(r.everyone).alter(r.serverOnly),
// Presence: everyone sees all cursors; you may only move your own. r.path("presence.$avatarId.{cursorX,cursorY}") .view(r.everyone) .alter(({ avatar, params }) => avatar.persistentId === params.avatarId || err_market.fromId("not_yours")),
// Vendor fast lane: a vendor sets their own prices directly, // with a server-checked floor. r.path("inventory.$vendorId.$produceId.pricePerKg") .view(r.everyone) .alter(r.all( ({ avatar, params }) => avatar.persistentId === params.vendorId || err_market.fromId("not_your_stall"), r.serverChecked(({ next, ctx }) => next >= ctx.priceFloor || err_market.fromId("below_floor")), )),],Path patterns
Section titled “Path patterns”A pattern addresses a set of leaves in the state shape. Patterns are resolved against the schema at the type level, so the rule functions get precise params, prev and next types.
| Pattern piece | Matches |
|---|---|
config.open | One exact leaf. |
config.* | Every child under config. |
presence.$avatarId | Each entry of a t.record — binds the key as params.avatarId. |
players.$playerId.{x,y} | A brace set: just x and y under each player. |
When several rules could match a leaf, the most specific pattern wins — and anything no rule matches is default-deny, for both viewing and writing. You opt paths in, never out.
Wildcards (*, **) need at least one further segment, so they never match the node they hang off — a wire-leaf (a primitive, a t.array, a t.schema) is addressed by its exact path: r.path("terrain"), not r.path("terrain.**"). defineRealm throws on patterns that could never match a schema path (a typo’d key, or a wildcard descending into a wire-leaf), so a dead rule fails at definition time instead of silently default-denying its branch.
View conditions
Section titled “View conditions”view decides which avatars receive a path’s values (and its changes). View conditions are coordinate-only — they see { avatar, params }, never state — because they’re evaluated per leaf when slicing broadcasts:
r.everyone— all avatar types (including replica-class avatars).r.avatarTypes("vendor", "auditor")— listed types only.- A plain function
({ avatar, params }) => boolean— e.g. owner-only:avatar.persistentId === params.avatarId.
A view rule’s avatar is type + persistentId only — never instanceId (reading it is a compile error). Broadcasts are sliced and cached per view signature (type, persistentId), so one avatar’s tabs and devices share one encoded payload; a view branching on instanceId would be silently mis-sliced across them. Per-instance visibility isn’t representable by design. (Alter rules run per write and do see the full identity including instanceId.)
A hidden branch’s bytes never leave the server. Broadcasts are sliced per view signature and encoded once per audience group — clients don’t “receive and ignore” hidden state; they never receive it.
Owner visibility is a state-shaping pattern
Section titled “Owner visibility is a state-shaping pattern”Want each consumer to see only their own orders while vendors see the orders for their stall? Don’t reach for clever conditions — shape the state so the coordinate is in the path, and denormalise per audience inside the intent that writes both:
state: { // keyed by the viewing coordinate — the view rule is then just an owner check ordersByConsumer: t.record(t.id("consumerId"), /* … */), ordersByVendor: t.record(t.id("vendorId"), /* … */),}Alter conditions
Section titled “Alter conditions”alter decides who may directly write a path with realm.update() (intents bypass alter rules — they’re trusted server code gated by their own allow list):
r.serverOnly— onlyengine.update()(server code) writes here.- A plain function
({ avatar, params, prev, next, state, basisVersion }) => true | NiceError— runs on the client as prediction and on the server as authority. Returntrueto allow, or a NiceError to reject with a typed reason. r.serverChecked(fn)— a check that needs server-only facts: the injectedctx, or the log-backed CAS helpertouchedSince(basisVersion). Client prediction auto-passes it — this is exactly the slice of optimism that can roll back.r.all(...conds)— compose: all must pass, the first NiceError rejects.
Because plain alter functions run in prediction, a disallowed write rejects locally and instantly — the rejection surfaces on the settle handle and in onMutationRejected, and nothing hits the wire.
Compare-and-set with touchedSince
Section titled “Compare-and-set with touchedSince”For the rare direct write that must not clobber a concurrent edit, a serverChecked condition can consult the patch log:
r.serverChecked(({ basisVersion, touchedSince }) => !touchedSince(basisVersion) || err_market.fromId("concurrent_edit"))basisVersion is the confirmed version the writer composed against; touchedSince asks “was this path modified after that?” If routinely you need this, the mutation probably wants to be an intent instead.
Replicas are an avatar rule
Section titled “Replicas are an avatar rule”A Worker (or any server-side process) holding a live read copy of a realm is just another avatar type — one whose view rules say “everything” (or a partial slice), connected with connectRealmReplica. No special authority, no second code path; the same rules decide what it sees. See Connecting & React.