Moss — Design Document
Purpose
A lightweight runtime that serves capabilities instead of code. A principal logs in, a charter resolves, and what arrives over the socket is their application: the actions they may use, the data those actions may touch, live, revocable, and identical in mechanism for humans and AI agents.
One sentence: the resolved catalog is the application.
Why it had to exist. The stack was server-shaped and had no server. Actions are wire-ready data and nothing wired them; the charter resolved but had nowhere trusted to execute; vex's multi-user machinery was built and dormant; server functions had no host; there was no transport anywhere. Six mechanisms, all built or specified, all homeless — and every app rebuilt the same wiring (its own shell, its own vex host, its own identity stub). Moss is the missing organ: the trusted place where the charter executes, the shell runs, and existence is served.
The inversion
A traditional application server hosts code and exposes endpoints. Everyone receives the same application; authorization is a wall of checks around it. Moss inverts the relationship on four axes.
1. It serves existence
The unit served is the action — the complete behavioral contract (layout +
triggers + endpoints + input). A principal's resolved catalog is their
application. Deny-by-nonexistence, made true by a server rather than cosmetic in
a client: an ungranted action is not hidden or disabled, it is absent. There is
no if (canAccess) to forget, because the thing to guard was never sent. The
warehouse kiosk granted two actions is not a locked-down app; it is a two-action
app.
2. Deployment is a data operation
Every field of the manifest is an authored artifact — the charter, the action definitions, the layout variants, the entries that are its API surface, the shell's canvases. Publishing a change is a write to that data; moss derives the rest. Consequences:
- No version skew. The shell and the definitions it runs live server-side; the client is a thin terminal that rarely changes.
- Rollback is a pointer move. The library is versioned; "published" is a pointer per (artifact, tenant, channel), and reverting moves it back.
- The release artifact is a diff, not a build log. Because permissions and UI are closed data, a publish can render its consequences in concrete terms — "crm.quotes → gained by: sales, intern, ray." A human approves that.
Today the manifest is what the app handed createServer: read by a schema,
then frozen, so an edit to it throws. Two parts of it change while a server
runs, each through one door: a set of actions is handed over
(server.replaceActions), a set of entries (server.replaceEntries), and
each is verified before it is taken. The artifact library
that makes publish a runtime write for every artifact is specified and unbuilt
(see below). The mechanism is already the function it needs to be: what a set
of roles holds, and the policy it reads and writes under, are derived from what
is in force and dropped when that changes, so there is no invalidation
protocol; a pointer in the database tells every other process that it did.
The pointer tells them that, not what: another process reads again the
sets of actions it has a loader for (the ones a feature keeps in rows), and a
set handed to one process with replaceActions or replaceEntries and no
loader is that process's alone. A library every process reads is the part that is unbuilt.
And there is one place it will be asked. Everything a shell loads that
somebody authored (its actions, its frame and layout store, its fragments, the
entries its reads replay) is answered by the running manifest in one function,
for a shell and a principal with their roles (artifactsFor, src/artifactory/running.ts). A shell is built
from that answer and adopts it: an adopt puts in place the actions that are
held now, and each { ref } of the layout store and each fragment that the
answer says differently from the last time. Every layout the shell draws is
one of those refs, under the name of what draws it: the answer holds an
action's in the layout store under action/<id>, and the definition names
it; the shell's own is under frame and a canvas's under canvas/<id>
(shellWithStoredLayouts, src/artifactory/artifacts.ts). So a layout is changed by setting
a ref, which a screen that is open follows with its data kept, and not by
registering the action again, which only a screen opened afterwards sees. It
is also how a saved layout reaches a screen: under an action's ref the answer
holds the principal's own layout that is marked in use, else the layout
variant their roles hold, else the one published for everybody, else the
build's. The first two tables of the
library are built for that: a row for each save and a row for each version it
holds, never changed (src/artifactory/saves.ts). A row is its principal's,
and vex keeps it so. What is published for everybody is the rows of one
principal no caller is, under that same rule: publishing is the save a person
makes, made by the app's own server code as that principal
(server.defaults), and no second mechanism. An action or a fragment
somebody generated is a row of theirs too: it is added to what their shell is
built from, under an id moss gave it, and it may only replay reads its owner
may replay (src/artifactory/generated.ts). A layout saved under frame or
canvas/<id> takes the place of that layout of the app's own shell, a
person's own over the published one over the build's; a page draws the ones
it declares. (The
canvases a manifest declares are what a shell starts with: an adopt does not
take them again, a reset does. Nova gives a shell a canvas when something is
first pushed onto it; an adopt takes out one the manifest does not declare
once no layout in force holds a slot for it, where one did: slottedCanvases,
src/artifactory/artifacts.ts.) The
entries vex replays it asks the running manifest for as well, before its own
cache table; nothing else in moss reads one of the four off what the app
handed over.
3. One gate, every kind of principal
The resolved catalog feeds the human's shell, an agent's tool policy, the function gate, and the boot verifier from one document. User permissions, AI guardrails, feature flags, and plan entitlements collapse into one charter resolving to one set, per principal.
4. The shell runs on the server
Nova's core is headless and its interchange is serializable (RenderNode[]
down, NovaEvent up), so the shell executes server-side and the client is a
canvas terminal: a registry, a socket, a renderer. State is authoritative — the
LiveView move, but streaming semantic trees, so the terminal is tiny and
swappable — five targets ship: the app's React kit (terminal/react), the
app's Vue kit (terminal/vue), nova's plain-DOM kit (terminal/dom), a line REPL (terminal/tty), and a
full-screen TUI (terminal/ink). Swappable is literal: mountTerminal
hot-swaps targets over one wire, the session and current trees surviving the
swap. The DOM target is the proof the terminal is trivial — nova's DOM adapter
and default kit, zero framework, zero config.
The cost is the round trip, and two rules cover the one interaction that cannot
wait for it: the terminal never overwrites the value of the focused input when a
new tree arrives (bound inputs are identified by model.path), and ui:model
events debounce. A canvas — or a whole app — may also opt out and run its shell
in the browser, with vex as the enforcement floor: this is where offline and
zero-backend PGlite live. It is an explicit degrade, not a mode menu. What is
built is the whole app: an app with its own shell, which moss does not serve.
One screen mixing client-side canvases with authoritative server ones —
authority islands — is what the design allows, because a canvas slot
renders a tree and is indifferent to whether it came from a local shell or a
socket; no render target of moss's does it today, each registers its slots
against the wire alone (src/terminal/targets.ts).
Derivation over configuration
The organizing principle: an app hands over artifacts and an environment, and everything mechanical is derived — never configured.
- The data layer stands up from what's present: the app's entries are the API surface (nothing is generated), and the grantable set is the introspected schema × vex's verb leaves. No table list is authored.
- Per-principal resolution is the charter applied at login: roles from the
app's
identityseam or its assignment rows, granted action ids (with a content-hash version token), the compiled vex scope policy, and the granted layout variants (ring 2). Pure; kept for each set of roles, not for each person, until what the app consists of changes. - The component registry on the server is name-only stubs, derived by
walking what mentions a component: the actions in force, the layout
variants, the shell's canvases, frame, layout store and fragments
(nova/reflect's
componentsOf— the walk is shared, not re-derived) — the actual components live in the terminal. - Coherence is refused, not documented. Whether an app may be served is
one verification (
src/manifest/verify.ts): every action is one nova reads, the charter verifies (verifyCharter, with nova's closure audit over the definitions in force), every entry'sreachnames a profile the behaviors declare, and nobody holds two layout variants of one action. It is asked at boot, at every change (refresh,replaceActions), and of a manifest that is not served (verifyApp); a boot or a change that fails it throws. The closure audit's findings are not errors: they are filed under each role'sissuesin the report, and boot neither refuses on them nor prints them. If it boots, the charter and the variants are coherent; a push to an ungranted action can still be in it.
The test of the principle: moss's core adds no new vocabulary. Reads are vex,
writes are vex, compute is functions, UI is actions, permissions are the
charter. The server is the composition, not a fifth language. (The integrations
feature does read a document of its own, the bundle a separate service
publishes about itself. That is one reason it is a feature, and not the core.
Of a bundle it reads the fields that are nisc's own: the integration's id, what
it asks to be allowed, its grammar stamps, its actions and its phrases. What a
product adds to those (a store listing, where a screen is placed, a document a
person edits) is the host app's vocabulary: a bundle carries it under
extension, the app supplies the schema that reads it, and moss keeps it
without reading inside. A package does not own a closed vocabulary for what is
open.)
Ring 2 — served layout variants
Per-principal UI difference is existence (ring 1) or a served variant (ring 2),
never a runtime capability flag in a layout. A layout variant (NiscApp.layouts)
is { action, layout } under a minted id; the charter's layouts section
selects who holds which. At shell build, moss puts the granted variant's
layout where that action's layout is kept before the shell exists, so every downstream render
and serialize is already per-principal and the terminal renders what it is served
— nothing on the wire says why a button is absent. The base layout is the floor
(the least-privileged holder's shape); variants enrich upward as grants, so
extends composes them and a forgotten grant fails closed. This is the charter's
extensibility paying off: one Section value, moss hands the universe in and
compiles it.
How the code is laid out
Moss answers five questions about one request, in this order, and each
question has one module that owns it. A module is a directory under src/
with an index.ts, and nothing else sits at the top of src/ but the main
entry.
- Who is calling?
identity: a token is a principal (the deployment's verifier, moss's stored credential, the browser's cookie and its seal), and a principal is a record (roles, scope values, what is installed for them). - What exists for them?
artifactory: what the app consists of right now, with one writer, and what a set of roles holds of it. Every artifact a shell is handed is asked of it: the actions, the frame and the layout store, the fragments and the entries, in one answer for a shell and a principal, and read off the app by nothing else. It keeps what somebody saved while the app runs (a layout, today) and answers it in the same place. It is the one holder of the entries too: the ones the app shipped and each set handed over, compiled as vex replays them. It stands onmanifest, the app as it was handed over: the types an app is written in, the schema it is read by and frozen under, and the one verification of whether what is in force may be served. - What may they read and write?
data: the vex engine, the policy a set of roles reads and writes under, the routes, an entry run as a charter role, and the tables with their migration. The engine's cache asks the artifactory for a fingerprint first, through two functions the server hands it, and its own table (vex_cache) for what the artifactory does not hold: a query written for an intent while the app runs. Nothing is copied into that table, and a copy an earlier release left in it is not replayed. So what a fingerprint resolves to is decided inartifactory, as what an action, a layout or a fragment resolves to is. - What are they looking at?
shells: the shell host, one shell's build, the stream of frames, and the screen read without attaching. - How does it reach them?
socketfor the live wire,documentsfor a screen drawn into a page.
Under them sit two modules every other may import: runtime, what a
deployment supplies (the database, the verifier, the limits, the telemetry
sink), and protocol, what both ends of the wire agree on (the messages, the
close codes, the delta codec, the snapshot a drawn page carries). Over them
sits server, which answers none of the five: createServer constructs the
modules, hands each the narrow things it needs, starts the features and mounts
the routes, one step to a line. Over server sits node, how moss runs on
Node: the listener, the ws transport, the built site, and the Vite dev plugin
(@niscorp/moss/node and /vite). It imports the server, and nothing imports
it.
A feature owns none of the five. It lives in a directory of its own, ships
from an entry point of its own, and the core never imports it: the app creates
it and names it in its manifest's features, and the server starts it with
the seams (FeatureSeams: every member one function or one narrow value of
the module that owns it). Feature and FeatureSeams are the contract between
moss and its two features, and are not exported: an app names a feature and
cannot write one. The seams are made for the feature they are handed
to, and what they reach is that feature's: its own tables, which it names on
itself before it runs (Feature.tables.names); its own set of actions, kept
under its name, which it alone writes; the documents in its own rows. The core reads and
writes no row of a feature, reads no header of one, and holds no setting of
one (the key the integrations feature signs with is that feature's own).
feature is the module that says what a feature and
its seams are: types, and no code. There are two features: integrations
(@niscorp/moss/integrations), and tide (@niscorp/moss/tide), a committed
write minted into tide as a fact. A feature that needs something the seams do
not offer does not reach for it: the seam is added to the module that owns
the answer first, as a change of its own.
The terminal side is two more modules, built and shipped apart from the
server: client, the wire, which imports protocol and nothing else of moss,
and terminal, the conductor and the render targets, which imports client.
Which module may import which is written down once, as a table a build reads:
scripts/check-modules.mts in the repository. That table is the truth, and
this is a copy of it:
| Module | May import |
|---|---|
runtime |
nothing of moss |
protocol |
nothing of moss |
feature |
runtime |
identity |
runtime, protocol |
manifest |
identity, feature |
artifactory |
identity, manifest |
data |
runtime, identity, manifest |
shells |
runtime, protocol, manifest, data |
socket |
runtime, protocol, identity, shells |
documents |
protocol, identity, shells |
server |
every module above, and no feature |
node |
runtime, protocol, identity, manifest, socket, documents, server |
integrations (a feature) |
runtime, feature |
tide (a feature) |
feature |
client |
protocol |
terminal |
client |
The table has no cycle, and what it leaves out is as deliberate as what it
names. shells does not import identity: who somebody is, it is told.
socket does not import artifactory: what somebody holds is a function it
is handed. artifactory and data do not import each other: the generation
pointer's row is data's, when it moves is the artifactory's, the entries
over the artifactory's two tables are written by the artifactory and replayed
by data, and each reaches the other as functions the server hands over. integrations does not
import data: what it reads and writes it
authors as vex entries and replays through a seam. Nor manifest: what it
asks about a bundle before letting it in (does a layout fit the kit, which
chains of steps never end) it asks through two seams. A feature imports feature, and runtime for where a span
goes, and nothing else of moss. A module imports another by its index.ts
and never a file inside it.
Four rules go with the table. A module's factory is handed a typed object of
the functions and values it needs, never the whole manifest and never the
whole runtime: those two are handed to createServer, and the wiring hands on
the parts. Each table and each long-lived piece of state has one owner, and
nobody else authors what is written to it. What crosses a boundary (a
manifest, a socket message, a route's body, a row, a token) is parsed by a
schema in the module where it enters, and no module holds a type assertion.
And a function is at most 300 lines (200 is fine; 300 is the ceiling), a file
at most 400 lines of code, a line at most 200 wide.
One owner, as the code has it. The owner of a table authors every statement on its rows. It is not always the module that runs them, or the one that made the table:
| Table | Whose | Where its statements are written | What is somewhere else, and why |
|---|---|---|---|
sessions |
identity |
identity/sessions.ts: five statements straight to the pool, before anybody is signed in |
Its table is made in data's one migration run, from a sequence identity declares |
moss_generation |
data |
data/own-entries.ts: two entries, one that reads and one that moves |
artifactory decides when it moves (artifactory/generation.ts), through a function data hands over |
artifact_saves, artifacts |
artifactory |
artifactory/saves.ts: three vex entries and the row rule they run under, replayed through a function data hands over (Data.artifactEntries) |
The two tables are made by the core's sequence (data/tables.ts, nisc.moss/5). Their rows are somebody's, unlike the pointer's: the entries are replayed with the caller's scope, and vex keeps every read and write to the rows whose principal is the caller's userId. What is published for everybody is the rows of the principal *, replayed with that as the userId by the server's own code and by no caller. Moss updates and deletes no row, so a stored body is not rewritten when a grammar moves: it is brought to the current grammars where it is read |
integrations, integration_actions |
the integrations feature |
integrations/entries.ts: vex entries, replayed through the seam the feature is handed |
The two tables are made by the core's own sequence (data/tables.ts, nisc.moss): that is the history every database has run, and it is not edited. The documents stored in integration_actions are brought to the current grammars by data, which holds the pool, when the feature asks through its seam |
data holds the engine and the one ledgered migration run, so every owner's
entries and every owner's migrations run through it. It authors no statement
on a table that is not its own. The app's own tables, and tide's when the app
keeps them, are migrated in the same run (runtime.tables) and are the app's
and tide's.
What holds the rules is a build that fails, not this page: pnpm check:modules (the table above, the doors, nothing flat, no cycle, and no
feature in the core), pnpm check:api (an exported name is a line in
api.txt, and a line there is an exported name), pnpm check:raw-sql (a
statement outside vex is counted, with its reason) and pnpm lint (no type
assertion, no any that becomes a typed value, no written type predicate, the
two sizes and the width).
Narrow inputs and one owner are held by none of the four: they are read
for in review, and the table above is what a review found, not what a build
checks. What each gate holds is said at the top of its script. The rules
as somebody changing a package follows them are in the application guide,
"Changing a package" (packages/nisc/AGENTS.md).
The socket
The authority channel. Transport-blind: the protocol speaks through a
four-function Connection seam (send/close/onMessage/onClose, and what
the upgrade request offered), and the RFC 6455 plumbing lives with each
runtime's entry (ws on Node in ./node, Bun-native later). Who a terminal is
is decided on that request, from its headers — a session cookie its browser
sent, or a token it offers as a subprotocol beside nisc — and never from its
address, which every proxy on the way logs. One connection per client carries every canvas — ten open
canvases are ten canvas ids on one pipe.
Down: hello (the resolved catalog on connect, and the protocol the server speaks — the terminal names its own on the upgrade), catalog (declared for
catalog pushes — not yet sent; terminals know it and ignore it), frame (the
canvas arrangement — a served layout of CanvasSlot markers), render (a
canvas's tree), render-delta (the same tree, against the last one — opt-in,
see below), session (a login grant), error. Up: event (a
canvas-tagged NovaEvent), publish (a channel message), resync (this
connection's copy of a canvas drifted; send whole frames), back (undo the
last navigation), popTo (jump to an instance already on a canvas's stack),
reset (below).
Operational shape, all falling out of "the message is state, not history":
- Backpressure. At most one pending tree per canvas per connection; a newer render replaces it, never queues behind it. A diff stream could never drop an update this way — which is exactly why frame deltas are written against the last frame sent, never against an intermediate that was coalesced away. Dropping renders stays free.
- Delivery. Events are fire-and-forget; the tree is the confirmation. No acks, no replay after reconnect — a replayed intention against a changed screen is worse than a lost one, so reconnect simply re-sends the current trees. The same rule holds before the first connect: nothing is sent on a socket that is not open and nothing is held for when it is. A server-drawn page (below) is on screen before its socket, so a press in that moment is dropped.
- Durability. The database is the durable thing; a shell is a warm cache,
rebuilt on the next connection from definitions and whatever the app reads
from rows as it builds. Evicting an idle shell, restarting the process and
resetcost the same and nothing more: what was only in the shell — the screens someone had opened, and what they had typed. This is why an idle sweep can exist at all. - Recovery.
reset(up) throws a session's shell away and serves its replacement. It names no canvas, because it is the recovery for a session whose canvases are the broken thing; it is protocol-level, so no action declares it and no charter grants it. This is not a convenience: a shell is server state keyed by principal, so a wedged one survives every gesture a client can make alone — reloading reattaches to it, and so does signing out and back in. Without a verb the client can send, a person watching a dead screen has no move left. - Going back.
back(up) undoes the last navigation. It names no canvas either, and for a related reason: back is one gesture over the whole shell, and a terminal is served trees, not stacks — it could not name the canvas that moved last if it wanted to. What back MEANS is decided where the stacks live: nova's shell keeps a journal of the positions its navigations displaced, andbackrestores the newest one it can. So the browser's back button, a TUI's Escape and an app's own control are one message, an app authors nothing to receive it, and the terminal holds no location that could drift out of step with the server's. It is also the answer to a real failure: with no history integration at all, a browser's back button unloaded the application. - A connection that drops. The trees a terminal holds stay on screen
while it reconnects: the current ones are re-sent when it does, and blanking
in between would show a working app as an empty one. The price is that a
screen left up by a dead socket looks exactly like a live one, so the
connection's status is on what every render target renders against, beside
the frame and the trees, and a change of it is an update like a change of
them. What is drawn for it is not moss's: the two console targets print a
line, and the three that draw into an element say it on that element
(
data-wire-status) and leave what it looks like to the app's stylesheet. The terminal holds one socket at a time — it lets go of the one it had before it makes another — so one connection writes the screen, whatever a person pressed while the last was still connecting. - Emptiness. A canvas whose layout renders no visible content is served as
[], so a terminal collapses chrome onlengthalone. AnActionSlotis a boundary, not content — visibility is decided by what's inside it. - Size. Two optional reductions — transport compression and frame deltas — sit under the protocol, both off the app's books. See below.
Where a browser keeps its session
A terminal served by the app it talks to does not hold its session. Its browser does, in a cookie no script can read, and sends it by itself with the two requests that need to know who is asking: the page, and the upgrade that opens the socket. A token in the page — in storage, in a cookie script can read — is there for anything else that runs in the page to take; one the page never has cannot be taken from it.
- The answer to the upgrade is where the cookie is written. A cookie is set by an HTTP response and by nothing else, and the upgrade is the one request every terminal already makes. So who is asking is decided before the request is answered, and the answer carries the cookie.
- A sign-in over the socket reaches it sealed.
session.granthappens on a socket, which cannot write a cookie; the token has to travel through the page to the next upgrade. It travels encrypted with a key the browser holds in a second cookie no script can read or set — the seal, given by the answer to the browser's first upgrade under a name (__Http-) a browser lets only an HTTP answer set. The page can hand a sealed sign-in back and cannot open it, and neither can anybody it is shown to: without that browser's seal it opens nothing. Nothing is kept here between the two requests, so it works whichever process answers the second, and it works for any credential — moss's own, or an app's provider's. The seal rides only the socket's path, so no page request carries it and a page drawn for nobody is still the same for everybody. - Only for the app's own page. A browser sends a cookie with any page's
request to this host, a stranger's included, and the socket is the whole
app. So the cookies are read and written only when the request's
Originis the host it was addressed to (or oneruntime.originslists, behind a proxy that rewritesHost). From anywhere else a terminal is who the token it offers says, or nobody. - No route answers to it. The HTTP surfaces read
Authorizationand nothing else, so no request another site can make a browser send does anything as the person. A page request does read it — every site may link to a page, and none can read the answer. - Its name is the terminal's token key, so several people can be signed in
on one origin, each in a seat. It ends with the port when the page is on
one: a browser keeps cookies by host, not by port, and two apps on localhost
would sign each other out. Over https it is
__Host-prefixed andSecure, which a browser lets nothing but this host set. - What the page sees is a flag (
<name>.held=1), with no secret in it: how a terminal knows to let go of a token it was holding, and that a page drawn for somebody was drawn for it. - Signing out says so. moss revokes its own credential, and a cookie that no longer resolves is taken back on the next upgrade. An app's own provider's token may go on resolving, so a terminal that was signed out says it is leaving, and the cookie is taken back whatever it holds.
- Any other terminal holds its own token — a process, a page served from somewhere else, a browser that refuses cookies — offers it, and is handed a sign-in as its token, as before. A token such a page of the app's own offers (one an earlier build left in storage) is moved into the cookie and let go of.
What a frame costs
A server-authored shell pays for its inversion in bytes: every change re-sends a canvas's whole tree, where a client-side framework would have re-rendered locally and sent nothing. That is the trade the shell exists to make, and it is worth measuring rather than assuming.
Two reductions are available, and they are deliberately separate things:
- Compression (
socketCompression, on by default) ispermessage-deflate, an RFC 7692 websocket extension. It is negotiated by the transport, invisible above theConnectionseam, and understood by every browser. Render trees are mostly repeated keys, so it earns roughly 3–4×. Its cost is memory, and it is not small: about 260 KB resident per connection at thewsdefaults, against about 43 KB for the shell being served. That trade is obviously right at hundreds of connections and obviously wrong at a hundred thousand, which is the only reason it is a knob rather than a constant. - Frame deltas (
shellFrameDelta, off by default) describe a changed canvas against the frame the connection already holds, as copy runs plus literal inserts (src/protocol/delta.ts— an encoder and a decoder, both readable in full, no dependency). Measured on Lyra: an in-place change (a keystroke filtering a list, one row's badge flipping) falls to 1–4% of the frame; a navigation, where the tree is genuinely different, lands near 80% and is therefore left whole.
The choice of algorithm is the interesting part, because the obvious one is
wrong. A structural diff — walk both trees, emit [path, value] for every
leaf that moved — is what a virtual DOM does and what most people reach for. On
a navigation it measured 98–114% of the frame it was replacing: worse than
useless, because every path string is overhead the frame itself doesn't carry,
and an unkeyed list reorder costs the whole list. A byte matcher has no
such cliff: it finds shared vocabulary between two trees that share no
structure at all, and it is indifferent to what the tree means. It is also far
simpler to prove correct, which for a layer that can silently desync a screen
is the dominant consideration.
Three rules keep it honest:
- One baseline per shell. Every connection is level with the host's
sentmap — attaching serves whole frames, and a change fans out to everyone — so one encode serves the whole room. - Decisively smaller, or whole. A delta is only sent at ≤60% of the frame. Below that it wins even after deflate; above it, deflate on the whole frame does as well and the terminal does less work. And a frame over 64 KB is not compared at all: the encode's cost follows the size of the frames, not of the change, on the thread every session shares — about 7 ms at 64 KB, a second at 2.7 MB — so past that length, on either side of the change, the canvas is sent whole before anything is spent on finding out.
- Checksummed, always. Every delta carries a hash of the frame it should
rebuild. A terminal that lands anywhere else discards the result and sends
resync— the failure this layer must not have is a screen that is silently wrong, and no saving is worth risking it.
Neither reduction is visible to an application, and neither changes a rendered
tree. A terminal that never advertises delta=1 is served exactly what it was
served before either existed.
Two design points worth naming:
- Transport → shell addressing. The wire tags every event with its canvas; moss stamps the canvas's active instance as the event's origin, and nova's own origin filter delivers it to that instance's triggers alone. The terminal never learns what an origin is — addressing translation is the host's job, where both vocabularies meet. This is why two instances of one action on different canvases don't cross-fire, with zero client-side logic.
- Identity is asked twice. The HTTP surfaces re-ask the
sessionverifier on every request; the socket asks at upgrade and then on a timer for as long as the connection lives. A verifier is the whole of what an app writes to give tokens a lifetime (null= expired), and moss never learns what expiry means — it only asks again. The socket half is not optional polish: asking once left an expired credential rendering happily while every endpoint the server shell called came back 401, because that wire rides the HTTP middleware. A live interface whose every load silently fails is worse than a lock screen. A verifier that throws is a fault, not a sign-out — a database blip must not become an outage, so only an explicitnullcloses. - Session lifecycle is capabilities, not channels. The in-process function
seam hands each session a
grant(token)and arevoke(). A login function grants (the token goes down every connection as asessionmessage; the terminals reconnect authenticated); a sign-out function revokes (every connection closes4403, the durable shell is evicted). The app calls capabilities; it never knows where the session machinery lives. On the wire,4401(stale token) and4403(sign-out) are the same recovery, not a retry: drop the token, reconnect anonymous — retrying with a stale token would loop forever.
Server shells
One durable nova Shell per authenticated principal — the socket is ephemeral,
the shell survives disconnect (reattach re-sends the current trees, and N
attached connections receive the same frames: shared canvases are the same
mechanism). Anonymous connections get a throwaway shell, disposed on detach,
and so does every connection of a page.
Every living shell follows a change. When what the app consists of
changes, each shell that lives is brought level with what its principal holds
now, in place: the kept ones, a visitor's, and a page's, in every host. A
host holds every shell it built from when its build lands until it ends, and
lets it go where it ends, so the walk is over the shells that live and no
other. A role change (invalidateIdentity) rebuilds every living shell of
that principal the same way: the kept one, and each one a page built for them.
Lifetime. A durable shell lives until sign-out, an operator reset, or the
idle sweep: nothing attached for runtime.shellIdleMs (default 30 minutes) and
it is disposed, rebuilt on the next connection. Only the idle clock is swept —
deliberately no absolute cap, because that would discard the state of somebody
who is working. There is no TTL on the session behind it; a shell going away is
not a person being signed out.
Enumeration and reset. shells.list() reports every living shell the
host keeps — principal, attached connections, age, idle time, and what is
mounted on each canvas. shells.reset(principal) disposes one and stands its
replacement in the same slot, carrying the attached connections across, so every terminal
receives a frame and current trees exactly as on reattach. Both exist because
the host owns the map: an app that keeps its own note beside it can only drift
from it, and a shell nobody can enumerate is one nobody can be helped out of.
Since everything a shell derives from is re-read at build, the replacement is
the screen boot would serve that principal now — which is also the whole cost:
a reset is not a repair that preserves what someone was doing.
One canvas cannot take the session down. Rendering is guarded per canvas. A tree that throws holds its last good frame and its neighbours keep rendering, rather than killing the flush pass, escaping a microtask uncaught, and wedging the shell for the life of the process.
The shell is built from the manifest: canvases and fragments are data; a canvas's
initial may be a candidate list, and the first action the principal holds
mounts — so login is the anonymous principal's application (the charter's
public grant) by derivation, not a special case. The role name is fixed and
the charter has to define it, as public: [] if it grants nothing: boot does
not check that it is there, and a request that resolves to a missing public
fails. inputs(session) and
seeds(session) are the app's per-principal boot hooks — boot data and boot
instances. Endpoint calls ride the server's own HTTP surfaces
with the session's token — the server shell is just another principal-bound
client, enforcement included.
A read that keeps answering
A shell's vex reads go through the session's wire — an in-process request
against this server's own mounts. For an entry declaring
refresh: 'reactive', vex can keep answering after it returned: every
committed write to a table the query reads, it refetches (once for everybody
who would get the same rows) and calls back with the caller's new answer if it
changed (vex DESIGN.md, "Reactive reads").
The wire is the whole seam (src/data/writes.ts). Toward vex it is a VexLive — a
signal and a callback, carried in the in-process request's hono env, where no
network request can put one. Toward nova it is the response's onChange, which
nova applies through the endpoint's own response and target; the shell
re-renders and the change reaches every attached terminal like any data change.
No app code, no channel, no reaction.
The follow ends when the call's signal aborts (nova aborts it on unmount), when the last subscriber leaves (nova unsubscribes when a newer call to the same endpoint lands), or at once when nobody takes the response up — a server function reading through the same wire follows nothing. An answer that changed in the gap between the response and nova's subscription is held and handed over on subscribe.
This retires most of "writers announce, viewers react" for data: a screen whose
reads are reactive needs no channel to stay current. Channels stay for signals
that are not data — a deck moving to its next slide mounts an action, which no
data update can do. Not built yet: carrying invalidations between processes
(the fabric would gain an invalidate-tables signal; until then each process's
rows TTL bounds it) and pausing the follows of a shell with nothing attached.
The document
A page request is the one moment a terminal has nothing: the socket is not up,
the script may not have run. Moss can answer it with the screen itself, because
the screen is a value. attach already sends a frame and a tree per canvas;
shells.snapshot returns the same thing without attaching anybody, a terminal
that draws to a string turns it into markup with the app's own kit, and the
app's own index.html goes out with the screen in it and the snapshot beside
it. The browser's terminal starts from that snapshot and adopts the elements
already there. A drawn page is a first frame delivered early, and nothing
else: the socket still attaches, still sends current trees, and is still the
authority. If the two disagree — the shell moved in between, the cookie and the
stored token are different people — the next frame simply repaints.
What it costs was measured before it was built (atrium, one core, warm): drawing a signed-in screen to a string is 0.4–0.7 ms; a whole anonymous document — build a shell, read it, draw it — about 0.65 ms, some 1,500 a second. The page carries its screen twice (markup and snapshot), 3–5 KB compressed.
The head of the page is part of the screen. A title, a description and a
preview card are facts about what the screen shows, so the page's <head> is a
node in a layout (nova's nova:head, holding the elements a head holds) and
rides in the same trees as everything else: renderDocument reads the
elements off the snapshot and writes them into the template's <head>, and the
terminal reads them off the wire to keep the page's head. Nothing was added to
the snapshot, the wire or the manifest for it. Where the page lives is known to
the writer without being told: the site plus the path the document was drawn
at.
Six things had to be true, and each is a small, separate decision:
- The read does not disturb anybody. A snapshot renders, and rendering can
pick up a change the flush pass has not sent.
attachhandles that by making the newcomer's frame the whole room's; a snapshot handles it by not touching thesentbaseline at all, so the next flush still finds its change. - It waits for the screen to be whole, but not for long.
seedsland a tick after the build, and mounts chain loads. Nova marks an instanceactiveonly after its mount hook has been awaited, so "the seeds are in and nothing isinitializing" is the shell saying its first screen is complete — a signal that was always there. The wait is bounded (300 ms by default): a document must not hang on a slow integration, and a skeleton is a legitimate screen. - Who is asking comes off the browser's session cookie, because a page request carries nothing else (Where a browser keeps its session). It is read here and never written: the upgrade is where it is set and taken back, and one writer cannot disagree with itself. A cross-site request that carried it could draw a page it cannot read.
- An anonymous page and its socket are two shells that must name their instances alike. A principal's page is drawn from their durable shell and the socket attaches to that same shell — same ids, nothing to arrange. Nobody has a durable shell: the page is drawn from one throwaway shell and the socket builds another. So the first is built under a random seed and mints its ids in order under it; the terminal names the seed on the upgrade; the second mints the same ids. Plain counters would have been simpler and wrong — the origin an event claims is checked against the canvas it names, and that check is only worth anything while a stale id from another shell names nothing.
- Who may keep a page follows from who asked. Drawn for nobody, a page is
the same for everybody and may be cached or written to a file; drawn for
somebody, it is theirs (
private, no-store). That is one function (insrc/documents/document.ts;renderDocumentanswers what it says as the page'sheaders), keyed on whether a principal resolved. No page, route or app declares which kind it is, so none can declare it wrong. - Nothing here can fail a page. A kit component that throws, a shell that will not build, a template without a root: the template goes out as it is and the terminal paints it the way it always did.
The route is the app's, and so are the template and the kit. Moss draws nothing
itself: terminal/react/server, terminal/vue/server and terminal/dom/server
are terminals like the others — handed a screen, they draw it — and the same
shape as lyceum's SSH door, which has drawn served trees inside a server
process since before this existed.
One thing a kit must hold up its end of: its first pass in the browser has to
draw what the server drew. Anything that differs between the two — the window's
width, a portal target, storage — may not decide a component's first render.
Lyra's kit answered "is the window wide" from window.matchMedia in a state
initialiser, behind a typeof window guard; the guard is the bug, because it
makes the two sides disagree about the same render, and on a wide screen React
threw the page's elements away. What a component writes onto <html> from an
effect (a palette) is not in the page until the script runs, so the app reads it
off the snapshot and hands it to the document. Each app holds both with a check.
Pages
shell is the app: one durable shell per person, the thing that rearranges
itself around whoever holds it. Not everything at a path is that. A welcome, the
docs, a published talk are read, not worked in, and a person who is signed in is
still the same person when they open one.
A page is a shell manifest and the path it is drawn at
(app.pages). It is drawn by the same machinery with the keeping taken out: a
shell is built from the page's manifest for whoever asked, read, and disposed.
Everything else is unchanged, which is the point —
- what exists on it is still ring 1. A strip that names the signed-in person is an action a member is granted, on a canvas whose candidates a stranger is granted none of. Being signed in changes what a page has, through the charter, and nothing in a layout asks;
- its reads still run under the caller's policy;
- its cacheability is still decided by who asked, in the one place above.
So a signed-in person on /docs is themselves — and still has no shell there.
Their app shell is neither built nor touched.
Does anything stand behind a page afterwards? Only if something on it can
still happen. Every reason a drawn screen is not finished is a declared part of
an action — a ui: trigger, a two-way binding, a channel it waits on, a read
that keeps answering — so the question is decidable from the definitions
actually mounted, without running anything. Nova reports those facts
(livenessOf); moss, which holds the entries behind each fingerprint, composes
the verdict (src/shells/snapshot.ts) and puts it on the snapshot as live. A page that
is not live is served as markup and its terminal opens no socket at all. A page
that is live gets a shell for as long as its connection lasts — per connection,
never per person. The one thing counted live without being read is code: a fn:
endpoint that something calls.
onSession does not run for a page. That is not an economy: session code is
arbitrary, and a host that ran it could no longer say what a page can do. It is
the app's, and the app's shell is always live.
Pages as files. exportDocuments is the same draw, for nobody, once per
path. It takes no credential and has no way to be given one, so a file is only
ever the page as nobody in particular sees it. Each file reports whether it is
live and which reads its markup was drawn with (frozen at the moment of
writing). A live page is still written — whether a server stands beside the
files is the build's to know.
What a path means, and what it does not. A path picks between the app and a page, and can seed a page's canvas with its parameters. It does not address a place inside the app: the app's shell is state, one per person, shared by every terminal they have open, and a URL that tried to name a position in it would be naming something two tabs cannot disagree about. Routing stays where the application guide puts it — shell state is the truth.
The function seam
The fn: escape hatch runs server-side, in-process, next to the durable shell.
The manifest's functions(session) builds handlers once per session, closing
over the living shell, who is calling (the principal, their roles, what they
hold), the server's own surfaces as that session (wire), and grant/revoke.
A session says who is calling and holds no key: it carries no database pool, no
operator key and no policy object, and what app code needs of those has a call
(DOCS.md, FunctionSession). Agents live here (their reads run under the
caller's policy — they see what the caller sees); keys are .env. This is deliberately the side-
effect-ful, session-bound half. The canonical, side-effect-free function — an
independently deployable endpoint under /fns — is specified and pending.
The write lanes
Vex is the choke point every application write passes through, which makes its write observer the one place a host can learn about all of them. Moss hears it once and offers the news two ways — and the split is a privilege boundary, not a convenience.
The app lane carries no rows. A declared reaction hears table, op, row
count and the unforgeable scope. That is enough to invalidate, resync, or
push a ping onto a living shell, and it is not enough to leak anything: a
receiver that wants data re-reads it under its own policy, through the same
governed wire as everybody else. The alternative — handing rows to a callback
— puts data in the one place this stack has no fence for, arbitrary imperative
code, and deliver payloads are pings for exactly the same reason.
The tide lane carries rows, because tide has the fence. It is a feature
(createTideFacts, @niscorp/moss/tide), not part of the core: it hears a
write through the one seam a feature is handed for that, and an app that runs
no tide names none. A fact is stamped
with the identity derived from the write's own scope, and the matcher offers
a fact only to reflexes running under that same identity. Tide is the one
place a row travels without being read — no policy is consulted on the way —
so that stamp is the whole boundary, and it is the app's naming rule, never a
listener's interest, that sets it. Stamping by who is listening would walk one
tenant's row into another tenant's effect, which is a defect this stack has
already had once and does not intend to have again.
Interest is declared, not discovered: reactions name their table up front
and moss routes by that name, so app code never string-matches a fingerprint
to work out what happened. The cost of the declaration is that a name nothing
writes fires nothing, quietly — derive the names from mutationEffect and
assert them, the way an app already asserts its charter.
Pacing belongs to the host, not the engine. Tide names the next instant it
wants waking for; the driver owns the timer, wakes on every ingest, and drains
to quiescence. The driver is tide's own (createTideDriver), as the Postgres
store is (@niscorp/tide/postgres): both were moss's, and they are tide's
pacing and tide's storage. The interval that used to stand in for this was both a latency
floor and a throughput ceiling — a chain advanced one hop per beat — and the
only periodic thing left is a janitor that finds nothing when nothing is
broken.
Model runs
An agent lives behind the function seam, so the app server is the one place that
sees every model call a session makes. The manifest declares a runs sink;
session.recordRun stamps at, principal and shellId and hands over a
RunRecord — which agent, which model, what it cost, and the whole exchange as
RunTurn[]: every message out, every tool call with its arguments, every result
back.
RunTurn is deliberately moss's own shape. Moss depends on neither an LLM client
nor an agent framework, and a client's message union leaking into the app server
would make one provider's idea of a turn the contract. The caller flattens
whatever it ran into this, once, at the seam that knows both sides. Arguments stay
a string because providers send them JSON-encoded, and re-encoding a parse is how
a malformed call stops looking malformed in the record.
Nothing is aggregated, and there is no cost in currency: per-model price tables
rot, tokens are the honest unit, and provider + model let pricing be layered on
later. Per person, per model, per agent and per day are group-bys over the rows.
No default sink. A bounded in-memory array would answer "the last few minutes, gone on restart", which is not a question anyone asks about an agent's behaviour. Unset means unrecorded, which is at least honest. The session rides along so a sink can persist through the CALLER's own governed wire rather than a privileged one — a record that could be written on another principal's behalf would be worth nothing.
What it refuses to be
| Temptation | Refusal |
|---|---|
| An auth provider | BYO identity. Who somebody is, the app decides at its own door (magic link is the default strategy); the server only asks who a token is. Moss does keep a credential an app may use for that: mintSession stores a token by its hash once the app has decided, and 'dev-open' trusts an unsigned one for a harness. Neither decides anything. |
| A framework | An assembly point. The intelligence lives in the artifacts; the server resolves, serves, pushes, enforces. Its core is eleven modules, each one question or the wiring, and a twelfth that runs it on Node (How the code is laid out). |
| A place where policy hides | Every opinion it enforces is a readable artifact: charter, scope policy, assertion. No annotations, no interceptor magic. |
| A second way to do anything | Reads are vex, writes are vex, compute is functions, UI is actions, permissions are the charter. Moss's core adds no vocabulary of its own, and holds itself to the same: the rows it keeps it reads and writes through vex entries, and the statements left outside vex are counted, each with its reason (pnpm check:raw-sql). |
| A web framework | A page is a shell manifest at a path; a drawn document is a first frame delivered early. No file-system routes, no layouts-of-layouts, no loaders, and no chain of middleware for an app to configure (one gate may be laid over the operator prefix, runtime.operatorGate). A page's <head> is not managed either: it is a node in a layout, written into the document like the rest of the screen. The template, the route and the kit are the app's. |
Deliberately unbuilt
On the record, so nothing reads as finished that isn't:
- The artifact library. Versioned artifact rows + snapshots + a published
pointer per channel, so publish is a runtime write, not a rebuild — the thing
§2 ("deployment is a data operation") is building toward. The open decisions
are tenancy overlays, draft/published channels, and live-shell migration on
publish (nova needs a
replaceActionthat rebinds live instances without dropping their data). Verify-at-boot becomes verify-at-publish, same gates. - The
/fnshost — named, side-effect-free, independently deployable functions under/fns, gated by catalog closure. - The projection model — per-identity state held in the DB, written-and- pushed, seeded on login with no AI call, held while the principal is offline.
- The working set (level streaming). For large apps and AI context budgets, the catalog itself streams: serve the graph neighborhood of a principal's active actions now, fault the rest in on demand. Level streaming for humans is context management for models — one mechanism, two products.
- Scale-out beyond sticky sessions pinned by session id. What crosses
processes today is invalidation only:
refreshmoves a generation pointer every process polls (src/artifactory/generation.tsholds the clock; the row is read and moved through vex,src/data/own-entries.ts), and an optional fabric (runtime.fabric,src/server/signals.ts) carries identity invalidations, nudges, and the word to adopt for one principal. A set of actions does not cross: a process that follows the pointer reads again the sets it has a loader for, and a set handed to another process withreplaceActionsalone never reaches it (DOCS.md, "Many processes"). A shell is still resident in one process. A drawn document adds one requirement to stickiness: the page request and the socket should land on the same process, or a principal's shell is built twice and the page repaints once. And one limit is each process's own: the integrations' webhook door lets 240 calls a minute through for each integration, counted in the memory of the process that takes them, so N processes let a vendor through up to N times as often. One ceiling for a deployment would be a store its processes share, with a write for every call; it is not built, and a deployment that needs it sets it at its proxy. - Opening the app at a position. A path that seeds the app's shell (a deep link to a record) is not built. It is not the same thing as a page's parameters: the app's shell is durable and shared across a person's terminals, so an opener has to be idempotent against whatever is already mounted, and decide what a reload means after they have moved on.
- Letting a page's connection go. A terminal that holds a token on a page drawn for nobody (a file, on a static host) connects, and is served a page shell for as long as it stays — even when that page turns out not to be live for them either. Closing such a connection once its frames are sent is unbuilt.
- A seal no script can supply, in every browser. The seal is given by the
answer to a browser's first upgrade, after the page's script has started; a
script already running then could set one of its own first, and open the
sign-in that follows. Its
__Http-name is what stops that — a browser lets only an HTTP answer set it — and a browser that does not know the prefix does not stop it. Nor does the name stop a server on a sibling subdomain from setting one; with a script in the page as well, that is the same opening.__Host-Http-would close it, and would put the seal on every page request, ending the sharing of pages drawn for nobody. Over plain http there is no such name, and nothing on the way is private either.
Boundaries
Moss composes the other nisc packages and owns none of their concerns: charter resolves and verifies, vex reads and writes under scope, nova is the UI (headless core + a framework adapter), prism is the endpoint transform language. Moss is the organ that was missing — the trusted place where the charter executes, the shell runs, and existence is served. See DOCS.md for the API.