Moss · DESIGN.md

This is DESIGN.md as @niscorp/moss 0.8.2 ships it. A coding agent reads it from node_modules. The file itself:

nisc.dev/moss/DESIGN.md

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 identity seam 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's reach names 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's issues in 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.

  1. 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).
  2. 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 on manifest, 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.
  3. 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 in artifactory, as what an action, a layout or a fragment resolves to is.
  4. What are they looking at? shells: the shell host, one shell's build, the stream of frames, and the screen read without attaching.
  5. How does it reach them? socket for the live wire, documents for 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 reset cost 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, and back restores 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 on length alone. An ActionSlot is 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.grant happens 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 Origin is the host it was addressed to (or one runtime.origins lists, behind a proxy that rewrites Host). From anywhere else a terminal is who the token it offers says, or nobody.
  • No route answers to it. The HTTP surfaces read Authorization and 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 and Secure, 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) is permessage-deflate, an RFC 7692 websocket extension. It is negotiated by the transport, invisible above the Connection seam, 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 the ws defaults, 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:

  1. One baseline per shell. Every connection is level with the host's sent map — attaching serves whole frames, and a change fans out to everyone — so one encode serves the whole room.
  2. 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.
  3. 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 session verifier 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 explicit null closes.
  • Session lifecycle is capabilities, not channels. The in-process function seam hands each session a grant(token) and a revoke(). A login function grants (the token goes down every connection as a session message; the terminals reconnect authenticated); a sign-out function revokes (every connection closes 4403, the durable shell is evicted). The app calls capabilities; it never knows where the session machinery lives. On the wire, 4401 (stale token) and 4403 (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. attach handles that by making the newcomer's frame the whole room's; a snapshot handles it by not touching the sent baseline at all, so the next flush still finds its change.
  • It waits for the screen to be whole, but not for long. seeds land a tick after the build, and mounts chain loads. Nova marks an instance active only after its mount hook has been awaited, so "the seeds are in and nothing is initializing" 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 (in src/documents/document.ts; renderDocument answers what it says as the page's headers), 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 replaceAction that rebinds live instances without dropping their data). Verify-at-boot becomes verify-at-publish, same gates.
  • The /fns host — 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: refresh moves a generation pointer every process polls (src/artifactory/generation.ts holds 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 with replaceActions alone 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.