@niscorp/moss
0.8.2
Patch Changes
5e07fbb: An app can hand moss the functions that reconcile its canvases:
NiscApp.reconcilers, novaReconcilers by name, and a canvas'sreconcilenaming one — in the app's shell and in a page's. Every shell moss builds holds them (the app's and each page's, the one a document is drawn from and the socket's), and a reconciler is handed the shellsession.shellis, which refuses what would change what the shell is made of. The boot is refused where a canvas names a reconciler the app does not hand over. A manifest withoutreconcilers, and a canvas withoutreconcile, are read and served as before.98695fb: Resets of a shell that arrive while another waits for its turn are answered by that one: a burst of
resetmessages costs two builds at most, the one under way and one more. A reset is still never answered by the build already under way.Before, every
resetmessage from any terminal, signed in or not, queued a whole build of its own: 200 messages were 200 builds, each asking who the person is and what they hold, and running the app'sinputs,phrases,functionsandseeds.b43afd8: Integration intake holds an endpoint's URL to where it is routed. A path under
/integrations/<id>/is refused when it resolves anywhere else (..,.,%2e,\) or holds a{{…}}template before its?, and a call to the app is refused unless it is one of the app's vex routes (/api/vex,/api/<resource>/vex).Before, intake read only the start of the URL as written, while the in-process wire resolves it before routing: an approved integration could reach another integration's proxy, or any route of the app, as the signed-in person. An integration whose bundle has such an endpoint is refused, with a sentence for each, when it is next registered; one imported before this release keeps the endpoints it was imported with until then.
6ba0eeb:
server.invalidateTag(tag)has every living shell adopt, in this process and in every other one the fabric reaches, asrefreshdoes: a function or an action the new roles do not grant is no longer held, and no shell is reset.Before, it only forgot the identity records, so after a role change applied through it a connected person kept calling the
fn:endpoints their shell was built with until they disconnected. Each call now asks the app'sidentityagain for every signed-in person with a living shell whose record is not held.
0.8.1
Patch Changes
8eaea60: The docs say where
server.checkFunctions()goes: after every route of the app's own, before it listens.Nobody's shell makes its reads in-process, and the server takes no route once it has answered one: a route mounted after the check throws
Can not add a route since the matcher is already built.moss's DOCS, nisc's AGENTS and the cli README say so.
0.8.0
Minor Changes
2076f80:
session.draft.addis whatsession.draft.putwas. Breaking.BREAKING — approved by moccadroid, 2026-10-18: rename
session.draft.put(...)tosession.draft.add(...). Nothing else about it changes: it takes whatsavetakes, what is added is in that one shell when the add answers, andsave,discard,show,removeandlistare as they were.cdc89cc:
server.checkFunctions()reads the app's functions and refuses what is wrong with them before anyone is served. Breaking.BREAKING — approved by moccadroid, 2026-10-18:
serveof@niscorp/moss/node, andnisc dev,nisc build,nisc checkandnisc start, refuse to come up on an action that calls a function the app has no function of, a charterfunctionsdeny that matches nothing, or two functions under one id. An app that hands a function only to signed-in sessions, with a shipped action calling it, hands it to every session (the function refuses whom it must), or is refused.An app's functions are known only once a session is:
functions(session)is called for each shell, and a session hook may reach for the server object, which the app holds only oncecreateServerhas answered. So the check is the app's to call, once it holds the server and before it listens;serveof@niscorp/moss/nodecalls it, and so donisc dev,nisc build,nisc checkandnisc startafter the project'sboot; each closes what it built where it refuses. It builds nobody's shell once, after everything is in force, and drops it. What that call returned is checked, and it refuses on:- an action, or a fragment a shell ships, that calls a function the app does not have:
"home" calls "auth.leave", which the app has no function of. Before, the call failed when a person pressed the button. An app with nofunctionshas none. A set of actions handed over while the app runs is held to the same at that refresh. - a charter
functionsdeny that matches nothing:Charter is incoherent over the app’s functions — refusing to serve:. - two functions under one id:
moss: functions(session) returned "auth.leave" twice.
What the charter only warns of (a grant that names no function, a function no role grants), the functions with no description, and a map of handlers are said at that point, once.
The cost is one anonymous shell built, as any visitor who is not signed in causes. Every check reads nobody's functions: an app that hands a function only to signed-in sessions is refused where an action calls it.
- an action, or a fragment a shell ships, that calls a function the app does not have:
Patch Changes
edd3271:
session.answerIntent(writers, asked)also replays a query that exists:askedmay be{ fingerprint, context }.It is answered as the caller, as an intent is, with what vex knows of the query: the context keys it binds, the ones the call left out (
meta.missingContext), its warnings. The wire answers a replay with the rows alone. A model that proves a query before it builds on it learns from this why a replay came back empty.f461b98: What a person generated and nobody has used for long enough is deleted, with
runtime.artifactsUnusedMs.- A version of an artifact a person saved is stamped as used (
artifacts.last_used_at, migrationnisc.moss/6) when a shell is built for them, once an hour at most, through the same vex entries and row rule as the save itself. - A sweep runs as the server comes up and then every hour: every artifact of a person whose newest version was neither saved nor used within
artifactsUnusedMsis deleted, every version of it, and the saves left empty with them. What is published for everybody (*) never ages; what the build ships is not a row. - Without the setting nothing is deleted.
0or less refuses the boot. - The sweep is one raw statement in moss (
data/aging.ts), entered in the raw-SQL ledger: vex keeps every statement of its own to one principal's rows, and the sweep crosses them all.
- A version of an artifact a person saved is stamped as used (
37422bf:
server.defaults(publisher)publishes every kind a person saves: an action, a fragment and a read, beside a layout.- An action or a fragment is given its id by whoever publishes it, as a shipped one has one:
server.defaults(who).save([{ kind: 'action', id: 'promo', body }]). One without an id is refused (moss: a published action is given its id by whoever publishes it: name it.), and so is one undergenerated.. The charter grants it by that id: a new id needs a grant, and one that is a shipped action's or fragment's replaces the build's for everybody until it is reset. - A read vex wrote is published under its fingerprint,
save([{ kind: 'read', id: '<fingerprint>' }]), and is answered to everybody, nobody signed in included, after what the caller saved themselves. One under the fingerprint of a shipped entry is refused. A generated action anybody saves may replay it. - Checked as a person's save is, as the publisher, and then as a manifest. An action's
fn:calls are ones the publisher's roles grant and its reads ones they may replay; then the whole set in force is verified as a set handed over is, and a holder whose roles do not grant a call refuses the publish:Publish refused — actions call functions the app does not have, or that their holders’ charter does not grant:. - A default that no longer passes under a new build is left out, and the build's copies stand, with a line said once per read of the rows:
[moss] what is published for everybody is left out, and the build’s copies stand: …. publishedArtifacts(saved, publisher)of the running manifest takes who publishes, for the roles and reads a publish is held to;server.defaultsresolves them from the publisher's principal.
- An action or a fragment is given its id by whoever publishes it, as a shipped one has one:
Updated dependencies [63b9ab5]
Updated dependencies [6a62604]
- @niscorp/[email protected]
0.7.0
Minor Changes
e9b987e: Moss holds an app's entries itself, in memory, and copies none into
vex_cache.server.replaceEntries(source, entries)hands a set of entries over while a server runs. Breaking.BREAKING — approved by moccadroid, 2026-10-12: an app that runs a vex engine of its own over
runtime.cachegives that engine the entries it replays (seedCache(cache, entries)), and an app that wrote an entry into that table for moss to serve hands it over withserver.replaceEntries(source, entries).- Where a fingerprint resolves. The entries an app ships are read from the build at every boot and held by the running manifest, compiled as vex replays them. The engine asks it first, and the cache table (
vex_cache, or whatruntime.cachehands over) only for what vex wrote itself while the app runs, such as a querysession.answerIntentwrote. A boot still refuses an entry vex cannot run. - Nothing is copied into the cache table, and a row in it that no request wrote is not replayed. Until now every entry was written there at each boot, and a replay resolved in the table. Moss now answers from the table only for a row vex wrote for a request (it carries that request's hash). The rows an earlier release wrote stay where they are and are answered from no longer: an entry the app stopped shipping is refused (
cache_miss), where it went on answering before. Moss deletes none of them. server.replaceEntries(source, entries)is new, and is to entries whatserver.replaceActionsis to actions: a named set, replaced whole, checked before it is taken (the schema the manifest's entries are read by, vex's authoring lint for a write, the reach it declares), frozen, and this process's alone. An entry of a set is replayed in place of one the app shipped under the same fingerprint.- The list of fingerprints a feature or a discovery is told of holds the entries in force and the queries vex wrote. An app whose
runtime.cachecould not list its rows (vex's tiered cache) was told of none; it is told of the entries in force now. nisc migrateis as it was. Its pass for an entry that already did not fit before a run asks the cache table for a copy of that entry. Moss writes no such copy from this release on, so in a database only moss writes to, an entry first shipped from now on gets no pass. A copy an earlier release wrote, or one the app writes itself for an engine of its own, still gives one.- What a discovery says of an entry of the app.
createdAtis when this process compiled it, andlastUsedAtis not said: a replay of an entry moss holds stamps nothing. Both came from the row in the table before. - The refusal for a set that is not the caller's says
the set "…"where it saidthe set of actions "…": a set holds entries too. - The CLI and
@niscorp/niscchange in the moss they bring;AGENTS.mdsays where entries are held.
- Where a fingerprint resolves. The entries an app ships are read from the build at every boot and held by the running manifest, compiled as vex replays them. The engine asks it first, and the cache table (
fc96c47:
functions(session)returns a list ofFunctionDefinition,{ id, description, execute }, andsession.functions()lists each function a session may call with its description.functions: (session) => [ { id: 'notes.archive', description: 'Archives one of the person’s notes. Send { id }.', execute: async ({ id }, signal) => archive(session, id, signal), }, ],idis what the charter'sfunctionsand everyfn:endpoint name.executeis what anfn:endpoint calls,(data, signal) => Promise<unknown>, as a handler in the map was.descriptionsays what it does and what to send, for the model that writes a call to it:session.functions()answers[{ id: 'notes.archive', description: '…' }].- Two functions under one id are refused when a shell is built:
moss: functions(session) returned "auth.leave" twice. - A map of handlers by id still works, and is deprecated. The first shell a process builds says once what to change, read off what that session was given:
[moss] functions(session) returned a map of handlers, which is deprecated: return a list of { id, description, execute }., or, for a list,[moss] functions with no description: auth.leave. Give each a description.An empty description counts as none; such a function, and one given by a map, is listed by its id alone. FunctionDefinitionis exported, for a helper that returns some of an app's functions.
For whoever upgrades an app, person or agent: turn the map into a list, one
{ id, description, execute }for each handler, the id being its key; a helper that returns part of the map returns part of the list, and the parts are joined with[...a, ...b]. Write each description as what the function does and what to send.9e6a7c3: The charter decides which of an app's functions a principal may call. Breaking.
BREAKING — approved by moccadroid, 2026-10-14: give every role of the charter a
functionspart, the functions its actions call, orfunctions: ['*']to keep calling all of them; a role written as a list (member: ['home', 'settings']) becomesmember: { actions: ['home', 'settings'], functions: [...] }.- A shell holds only the functions its principal's roles grant (
functions, besideactions,dataandlayouts, with the same globs,extendsanddeny). A role with nofunctionsmay call none. Before, every shell held every function. A living shell follows the roles an adopt answers, as its actions do. - The boot is refused, with the list of what to grant, where a role on its own, a combination people wear, or
publicholds an action (or the shells ship a fragment) that calls a function its grants miss:"member" hold "chrome.topbar", which calls "auth.leave": their functions do not grant it. A name with a template in it is decided when it is called. '*'is said at boot:role "member" allows every function ('*'): the generated actions of the people who hold it, or a role that extends it, may call any it does not deny.- A grant that names no function (a typo) is said at the first shell a process builds, with a deny that matches nothing and a function no role grants: the names of the app's functions are known once
functions(session)has run, and are read as that first session's. - A generated action or fragment may call a function its owner's roles grant, named written out; one they do not grant is refused when it is saved and left out when a shell is built.
session.functions()lists the functions a session may call,{ id, description? }, for the model that writes one. @niscorp/charterhas thefunctionssection, andverifyCharterchecks it where afunctionsuniverse is handed in.
For whoever upgrades an app, person or agent: boot it. The refusal lists, per role combination, each function an action calls that the charter does not grant. Add those names to the
functionsof the role that holds the action, or['*']to the roles whose people may call everything. This is the app's permissions table: write it deliberately, and do not generate it.- A shell holds only the functions its principal's roles grant (
b7fabdb: Moss keeps every action's layout in the shell's layout store, under
action/<id>. A layout that changes now reaches a screen that is already open. Breaking.BREAKING — approved by moccadroid, 2026-10-12 (
action/) and 2026-10-13 (frame,canvas/): a manifest'slayoutStoredeclares no name beginning withaction/orcanvas/and not the nameframe, no action's layout and no layout variant names one of those by ref, and code that takes the whole shell (unverifiedShell(session)) to read an action's layout off a definition reads it from the layout store (shell.layoutStore.get('action/<id>')).- An open screen follows a layout that changed, with its data kept. A set of actions handed over with
server.replaceActionsused to reach only a screen opened afterwards: an instance that was open kept the layout it was opened with. What an open instance still keeps is the rest of its definition (its data's shape, its endpoints, its triggers). - Where the layout is kept does not show to an app. An app writes an action's layout in its definition as before.
server.actions()answers it as written, and so does a definition read off a session's shell (session.shell.getRuntime(id).definition), with the layout the screen is drawn with now in place. Inside nova's own shell, whichunverifiedShell(session)hands out, the definition names its layout by{ ref: 'action/<id>' }and the store holds the layout, or the layout variant the person's roles hold. An action with no layout, or one whoselayoutis the name of a stored layout, is handed over as it is. - A session's shell hands out its layout store to read.
setanddeleteonsession.shell.layoutStorethrow, assetLayoutdoes. They went through before. action/is moss's in a layout store. A manifest whoselayoutStoredeclares a name beginning with it, in the app's shell or in a page, refuses the boot and says where. So does an action's layout or a layout variant that names one, at boot and when a set of actions is handed over: a layout cannot be drawn inside itself.- The layout of an action that leaves a shell is taken out of its store.
- The shell's own layout and a canvas's are kept in the store too, under
frameandcanvas/<id>, and the shell draws each by that name. An app writeslayoutand a canvas'sactionLayoutas before, and a screen is drawn as before; one that is the name of a stored layout is handed over as it is. A manifest whoselayoutStoredeclaresframe, or a name beginning withcanvas/, in the app's shell or in a page, refuses the boot and says where. So does an action's layout or a layout variant that names one of them, at boot and when a set of actions is handed over. A layout is named by a node that is{ ref }and nothing else: arefa component carries, on its node or in its props, names none. - Whether a page can still do something is read through every ref. A layout a screen names by
{ ref }is read where the store holds it, so a two-way bound field in a stored layout the app names itself makes its page live. It was missed before, and such a page was reported as a file. - The CLI and
@niscorp/niscchange in the moss they bring.
- An open screen follows a layout that changed, with its data kept. A set of actions handed over with
Patch Changes
0a7beae: Moss's source has a module for what the running app consists of,
artifactory. Nothing an app imports, calls or is handed changes.- What is in force while a server runs, what a set of roles holds of it, and the clock of the generation pointer are in
artifactory, where they were inmanifest.manifestkeeps the app as it is handed over: its types, its schema and its verification. DESIGN.md, "How the code is laid out", says which module answers what.
- What is in force while a server runs, what a set of roles holds of it, and the clock of the generation pointer are in
4dc982a: Moss keeps what was generated while the app runs for the person who saved it (a layout, an action, a fragment), and a layout an app publishes for everybody.
session.artifacts, on the session an app's functions are handed.save,list,history,restoreandreset. A layout is saved for the action it is for ({ kind: 'layout', body: { ref: 'action/<id>', layout, use? } }). It is the session's principal's alone, gets its id from moss, and is drawn on their screens while it is markeduse: true; their open screens follow at once, with their data kept. Who may save is the charter's: the function that calls it is the app's, and the charter says who holds the action that calls that function. A session nobody is signed in to saves nothing.- A row is its principal's, and vex keeps it so. Moss reads and writes the two tables as the caller, under one row rule: a caller reads and writes only the rows whose
principalis theiruserId, and a row they insert or update carries it. No call names whose rows are meant. The same rule is part of the policy an app's charter compiles to, so a role that is granted the tables, by name or by a wildcard, reaches its own rows there and nobody else's. A row ofartifact_savesis moss's alone to make: an app's entry that inserts or updates one is refused. An app'sbehaviorsthat nameartifactsorartifact_savesrefuse the boot. server.defaults(publisher), on the server: what is published for everybody. The same five calls and the same body. A published layout is drawn for everybody who holds its action; every living shell adopts it at once, and every other process follows the generation pointer, which a publish moves. It is kept as the rows of one principal no caller is (*), under the same row rule, with who published increated_by. No session can publish: only the app's own server code reaches the call, and the charter says who holds the action that makes it. One read of what is published answers every shell a process builds: a boot makes it, and the first shell after a publish there or a move of the pointer makes it again.session.artifactsalso keeps an action and a fragment (kind: 'action',kind: 'fragment'). Moss gives the id,generated.and a uuid; a body that names another is refused. It is its owner's alone: it is in the shells built for them and in nobody else's, and in no catalog. What it defines replays reads and calls the functions its owner's roles grant: each endpoint is aPOSTto the app's vex route naming one fingerprint of a read the app ships or was handed and its owner may replay, or of a read vex wrote that its owner saved, or anfn:endpoint to a function its owner's roles grant (see the charter'sfunctions); a write, a query a model wrote that nobody saved and every other address are refused, and so is a generated action that opens an action its owner does not hold. Moss checks the artifact; once it is in a shell it is an action or a fragment like any other, on nova's channels and in nova's merge of a fragment into the action it is wrapped around. It is checked at the save and at every shell, and one that no longer passes is left out with a line.session.actions, and theactionstheinputsandseedshooks are handed, are the ids the shell was built with, so they hold a principal's generated actions too.session.draft, on the same session: what is being generated in one shell, before it is saved.puttakes whatsavetakes, runs the same check with the draft laid over what is in force and against what that one shell can draw, and answers when that shell has it, so a function puts an action and opens it on its next line.list,removeanddiscard; each put or removal is a numbered step, andshow(step)has the shell show the draft as it was after it;saveis one save of all of it under the ids it had. A draft is in no other shell, is saved nowhere, and is gone when its shell is.- An id beginning with
generated.is moss's to give. A boot andreplaceActionsrefuse an action under one, and the manifest's parse refuses a shell's fragment under one. - No caller's
userIdis*, and no caller's scope carriesartifactSavedBy. For one whose sign-in orscopesays either, no shell is built and/api/vexanswers 500. - Under an action's ref a shell draws, the first of these there is: the person's own layout marked in use, the layout variant their roles hold, the published layout marked in use, the build's.
- Every save is a version, and moss changes and deletes none. Going back to a version is a new save that holds a copy of it; a reset is a new save that says none is in force, and the layout under it is drawn again.
- A layout is checked when it is saved and when a shell is built: its shape, that the action it is for has a layout of its own and is held by whom it is saved for, its components against what the app's shells have, that it names no ref moss keeps a layout under (
action/…,canvas/…,frame), and what nova's audit finds of its action with the layout in place and not of the action as it is built. A save is refused with a sentence. A saved layout that no longer passes is left out with a line in the log, and what is under it is drawn. - Two tables, in migration
nisc.moss/5:artifact_savesandartifacts. A deployment that boots withmigrations: 'verify'runsnisc migratefirst, as for every migration. An app whose database already has a table by either name renames its own before it takes this release. - The fabric carries one more message,
adopt: it names a principal and tells a process to bring the shells it holds for them level. A process that runs an earlier release drops it as a message it does not know, as it drops every such message. - The layouts of the app's own shell are saved the same way: its own under
ref: 'frame', a canvas's underref: 'canvas/<id>', by a session for its principal and byserver.defaultsfor everybody. The app's own shell draws the person's own in use, else the published one, else the one its manifest declares; their open screen follows at once. A page draws the layout it declares. A shell manifest with nolayout, or a canvas with noactionLayoutof its own, has none to take the place of, and a save for it is refused. It is checked as a layout for an action is, against the components of the app's own shell and without nova's audit. A frame that leaves a canvas of the shell out is saved as it is: that canvas is drawn nowhere. - A canvas that only a layout's slot draws goes when the slot goes. Nova gives a shell a canvas when something is first pushed onto it. Where a layout in force for a principal holds a canvas slot with a written-out
canvasIdfor a canvas their shell's manifest does not declare, and then no layout does (the saved layout was reset, the action left the shell), an open shell loses that canvas with what is mounted on it. It stayed before, drawn nowhere. The canvases a manifest declares are never taken out, and moss gives a shell none. - A read vex wrote is kept the same way (
{ kind: 'read', id: 'fp_…' }, no body): moss takes the query and the Prism config of its mapping from vex's table. It is answered to the person who saved it and to nobody else (never to a caller nobody is signed in for), held to vex's limits as a query vex wrote is, it survives a schema change that does not touch what it reads, and their generated actions may replay it. One whose query no longer runs is left out with a line, with the generated actions that replay it. Moss's data layer hands vex's table only what vex wrote for a request: a saved read vex replays is not copied into it. - Not built yet: publishing an action, a fragment or a read; a layout of a page's shell.
- The CLI and
@niscorp/niscchange in the moss they bring.AGENTS.mdgains rule 7b: how a layout, an action or a fragment generated while the app runs is saved or kept in a draft, how a layout is published for everybody, and that the charter decides who may do either.
c7c4f9b: A nova shell keeps actions and fragments in one registry. Breaking.
BREAKING — approved by moccadroid, 2026-10-13:
shell.registerFragmentis gone, and an action and a fragment no longer share an id.shell.registerAction(definition)takes an action or a fragment, told apart by the fragment'skind: 'fragment', andshell.removeAction(id)takes either out. There was no call to take a fragment out. Taken out, a fragment is unknown to a laterwith: [...]; an instance that was opened with it keeps the definition it was composed into, andbackskips a position that was opened with it, as it skips one it cannot put back.- A fragment handed to
createShellis validated, as an action is: one that does not parse throwsDefinitionValidationErrorat construction. OnlyregisterFragmentvalidated one before. - An id names an action or a fragment, never both.
createShellandregisterActionthrowDefinitionValidationErrorfor an id that is already the other kind's. - Opening a fragment by itself still throws
UnknownActionError, and awith: [...]that names an action still throwsUnknownFragmentError.ShellConfigkeepsactionsandfragments. - Moss: a fragment that is no longer answered for a principal is taken out of their open shell, as an action is. A boot, and a set of actions handed over, refuse an action under the id of a fragment a shell ships. On a session's own shell,
registerAction(refused there as before) is the one call that would take a fragment.
What to change:
shell.registerFragment(fragment)becomesshell.registerAction(fragment). Give an action and a fragment that share an id two ids.Updated dependencies [c7c4f9b]
Updated dependencies [4068f98]
- @niscorp/[email protected]
- @niscorp/[email protected]
0.6.0
Minor Changes
server.invalidateTenant(tag)isserver.invalidateTag(tag). Breaking.BREAKING — approved by moccadroid, 2026-10-12: call
server.invalidateTag(tag)where the app calledserver.invalidateTenant(tag).- It forgets every identity record that carries one cache tag (
IdentityRecord.tag). The field named the mechanism and the method named one use of it, a tenant, which moss does not know. The method is named for what it takes. It does what it did. - The signal it sends the other processes of a deployment is
invalidate-tag, where it wasinvalidate-tenant. Processes on the old and the new release do not hear each other's during a rolling deploy: a record forgotten in one is forgotten in the other when it is next resolved again (sessionRevalidateMs). - Nothing else of moss changes, and the CLI and
@niscorp/niscchange only in the moss they bring.
- It forgets every identity record that carries one cache tag (
0.5.0
Minor Changes
0b25acc: Moss is rebuilt as modules, and its public API is what an app is meant to call. Breaking.
BREAKING — approved by moccadroid, 2026-10-09: import only what
@niscorp/mossstill exports (the list ispackages/moss/api.txt); the changes an app has to make are listed below, one per part.Moss had grown to twenty-eight files flat in
src/, one of them a single function of 1,345 lines, and it exported 135 names of which 71 were imported by nothing outside the package.docs/plans/moss-refactor.mdsays how moss is built now; the audit is in that file's history.How to read this. The parts are in eight groups, in the order an app meets them: (1) what is imported from where, (2) the manifest, (3) the session and who is calling, (4) integrations, (5) a write and tide, (6) the socket and the shells, (7) pages and the Node entry, (8) the terminal. Each part says what changed and what an app writes instead, and a part that asks nothing of an app says so. The last part, "What an app changes", is the list to work down.
(1) WHAT IS IMPORTED FROM WHERE
The main entry exports 30 names, not 135. All eighteen entries together export 90, where fourteen exported 185.
106 names left the main entry and one arrived (
verifyApp). Every name that left is in this table, with what to write instead. A name stays where it is the one way to do something an app is meant to do, or the type of something an app writes; a second way to what the server answers, a part of something exported whole, and a type that only names what an exported function takes or answers and that nothing imports, left.Was imported from @niscorp/mossWrite instead createAssertionSignerthe same name, from @niscorp/moss/integrationsthe type StorePressnothing to import. A host copies a listing's images itself, in extension.prepare("What a host with a store takes over", below)integrationOfAction(actionId)integrations.integrationOfAction(actionId), on the objectcreateIntegrationsanswersthe type CallIntegrationIntegrations['callIntegration'], withIntegrationsfrom@niscorp/moss/integrationscreateTideStore,TIDE_SEQUENCE,TIDE_TABLES,TIDE_DDL, the typeTideStoreOptionsthe same name, from @niscorp/tide/postgrescreateTideDriver, the typesTideDriver,TideDriverConfigthe same name, from @niscorp/tidethe type FactIntakePick<Tide, 'ingest'>, withTidefrom@niscorp/tide: whattideincreateTideFactsanswersintegrationByKey(pool, key)integrations.integrationByKey(key), on the objectcreateIntegrationsanswers ("Integrations are a feature", below)listAttachments(pool, hostAction),listPlacements(pool)integrations.listIntegrations(): every registered integration with its storedextension, where the host's own vocabulary keeps what is attached and placed ("A bundle is nisc's own fields andextension", below)callIntegrationWithintegrations.callIntegration(id, path, init)listIntegrations(pool), the typeIntegrationRowGET /operator/integrationswith the operator key answers{ integrations }describePlacementsnothing to import. The sentence is the host's to write, from its own vocabulary: in extension.prepare, kept with the rest of the extensionbuildContract,contractAsMarkdown, the typeContractGET /api/integrations/contract, and the same with?format=mdrunIntake,copyPress,initIntegrations, the typesBundle,IntakeContext,IntakeResult,PressResultnothing to call. Registering an integration ( POST /operator/integrations) runs intake, andcreateServerandmigrateTablescreate the tablesmintIntegrationKey,hashIntegrationKeynothing to call. A key is minted when an integration is registered and answered once; integrations.integrationByKey(key)says whose a key isverifyAssertion, the typeAssertionan integration's service verifies an assertion itself. The token is <body>.<signature>, both base64url; the signature is ed25519 over the body's bytes, under the keyGET /api/integrations/verify-keyanswers (SPKI DER, base64); the body is JSON,{ integration, principal, scope, iat, exp }the type AssertionSignerReturnType<typeof createAssertionSigner>resolveCatalog(app, principal)(await server.catalogOf(principal)).idsresolveCatalogForRoles(app, roles, installed),filterInstalled(await server.catalogOf(principal)).ids, for a principal who holds those roles: the server resolves their roles and their installs the way a request does. There is no asking by roles and a list of installs the caller wrote downthe type CatalogAwaited<ReturnType<MossServer['catalogOf']>>resolvePolicyForRoles(app, grants, roles)server.policyForRoles(roles)resolvePolicyAtReachnothing to call. The routes and server.executeAsserve an entry at the reach it declares: replay the entryresolveRoles(app, principal)(await server.identity(principal)).rolesresolvePolicy(app, grants, principal)server.policyForRoles((await server.identity(principal)).roles)resolveVariants(app, principal),resolveVariantsForRoles(app, roles)nothing to call. The shell a server builds for somebody has the variant they hold in place of each action's layout, and server.charterReport()andverifyAppsay whether the variants are coherentauditClosure,wearableOf,verifyVariantsserver.charterReport()for what a server is serving,verifyApp(app, grants)where there is no serverloadIntegrationActionsserver.actions()memoKeyOf(app, principal)nothing to call: it was the key of memos nothing outside the server can address. To learn whether two principals are served the same application, compare (await server.catalogOf(principal)).hashcreateShellHost, the typesShellSession,ShellOpening,ShellReportserver.shells, andserver.page(path)?.hostfor a page. The types areAwaited<ReturnType<ShellHost['session']>>,Parameters<ShellHost['session']>[2]andReturnType<ShellHost['list']>[number]shellNeedOf, the typeShellNeedliveandwhyon whatshells.snapshot(token, principal)answers; for a build, on each result ofexportDocumentscreateSocket, the typesSocketAccept,SocketContextserver.socket, whose type isMossServer['socket']the type ClientMessagenot exported. The six messages a terminal sends are listed under "A message on the socket", below PROTOCOL,CLOSE_INVALID_TOKEN,CLOSE_SIGNED_OUT,offerToken, the typesServerMessage,Connectionthe same name, from @niscorp/moss/protocol:import { PROTOCOL, offerToken } from '@niscorp/moss/protocol'. It is for code that speaks the wire itself (a client written by hand, a check that opens a socket, a transport other than@niscorp/moss/node); a terminal on@niscorp/moss/clientand an app served byniscorserveneed none of itCLOSE_SHELL_FAILEDthe number, 4500createDataLayer, the typeDataLayernothing to call: createServerstands the data layer up.server.executeAsreplays an entry as a charter role, andsession.answerIntentruns a query written for an intentcreateGeneration,GENERATION_DDL,DEFAULT_GENERATION_POLL_MS, the typeGenerationnothing to call: the counter is the server's, and server.generation()is gone.server.refresh()andserver.replaceActionsmove it. Its table is part ofMOSS_SEQUENCE, and it is polled everyruntime.sessionRevalidateMs(60 seconds when unset)createIdentityCache,DEFAULT_IDENTITY_MAX,DEFAULT_IDENTITY_IDLE_MS, the typesIdentityCache,IdentityCacheContextdeclare identityon the manifest. The limits areruntime.identityMax(10,000 when unset) andruntime.identityIdleMs(30 minutes);server.invalidateIdentity,server.invalidateTenantandserver.identitiesare the restthe type IdentityReportReturnType<NonNullable<MossServer['identities']>['list']>[number]DEFAULT_IDLE_MS,DEFAULT_REVALIDATE_MSthe settings they were the defaults of: runtime.shellIdleMs(30 minutes when unset),runtime.sessionRevalidateMs(60 seconds)devSessionsession: 'dev-open'("The dev verifier is not exported", below)sessionVerifierOf(runtime)server.principalOf(token), from outside the verifier: it asks whateverruntime.sessionisthe type SessionVerifierExclude<NiscRuntime['session'], string>revokeSession(pool, token)revokeAllFor(pool, principal), orsession.revoke()in a function: both sign the principal out everywhere. No exported call deletes one token and leaves the principal's othersSESSIONS_SEQUENCE,initSessions(pool)nothing to call. With session: 'sessions',createServerandmigrateTablesmake the table, in the one ledgered runsessionOf(pool, token)with session: 'sessions',server.principalOf(token): that deployment's verifier is this lookup. Nothing to call from inside a verifier of the app's own: see "Moss's stored sessions are whatsession: 'sessions'verifies with", in group 3wireFabric, the typesFabric,FabricSignal,FabricMessage,FabricApplyset runtime.fabric. Its type isNonNullable<NiscRuntime['fabric']>, and a message is what itspublishis handedemitterOf,spanClock, the typesEmit,SpanClocknothing to call. A sink goes in runtime.telemetry(Telemetry), and moss catches what it throwsmintWritesname createTideFacts(...)infeatures("Tide's hosting is tide's", below)SNAPSHOT_ELEMENT_ID,embedSnapshot(snapshot, principal, path?)readDocumentSnapshot()reads the element andrenderDocumentwrites it, in the page it draws. Its id isnisc-snapshotdocumentHeaders(principal)(await renderDocument(config)).headerstokenFromCookie(header, key?)nothing to call. No route of an app answers to the session cookie: renderDocumentreads it to choose whose screen to draw, the socket's upgrade reads it, and a sign-in the app answers sets it withsessionCookiescreatePageRouter(pages), the typesPageRouter,PageMatchserver.page(path): the page a path leads to, its host and the input its path carriesthe type TablesReportAwaited<ReturnType<typeof migrateTables>>Where the right column says there is nothing to call and an app did call it, say which and why in an issue: the answer is a call that is meant to be public, not the name coming back.
Integrations are a feature:
@niscorp/moss/integrations, named infeatures.Integrations (services that add screens: registration and approval, the proxy, webhooks, the integration key) are not part of moss's core any more. They ship from an entry point of their own, and an app that uses them creates the feature and names it in its manifest:
import { createIntegrations } from '@niscorp/moss/integrations'; const integrations = createIntegrations({ integrationActor, extension }); const app = defineApp({ charter, actions, features: [integrations] });Nine fields leave
NiscApp. One is configuration ofcreateIntegrations, under the name it had:integrationActor. Seven named one product's vocabulary and are no option of anything:attachable,menuSlots,assistantTools,publishChecks,editorRegions,placementNames,storePress. What a host says with them it says in its ownextension("What a host with a store takes over", below). The ninth,installedIntegrations, was deprecated and never called, and is gone. A manifest that still names one of the nine is refused at boot, as any field the type does not have is:(the manifest): Unrecognized key: "menuSlots".NiscApp.featuresis new. The server starts each feature, in order, once its core stands and before anything is served.FeatureandFeatureSeamsare not exported: the two features are moss's own, and an app cannot write one.An app that does not name the feature has no integrations. None of
/api/integrations/*,/integrations/:id/*and/operator/integrationsis served, anik_key is read as a session token and refused as one (invalid_token), and no integration's actions are loaded.integrationsandintegration_actionsare still created, becausenisc.mossmade them and still does; no row of them is read, written or served. The stored actions used to be brought to the running code's grammars at every boot, whatever the app named: the feature has that done when it starts, and a row written by newer code refuses the boot of an app that names the feature, as it did of every app.installednarrows what somebody holds only where the feature is named. The install list an app'sidentity.resolveanswers (installed) took everyext.<audience>.<integration>.<name>action out of what a principal holds where that integration is not in their list, in every app. The rule is the integrations feature's, and it is laid when the feature starts. An app that ships actions underext.…ids of its own, answersinstalled, and names no feature serves those actions to everybody the charter grants them, whateverinstalledsays. Such an app names the feature,features: [createIntegrations()], which brings the rule back with the rest. No lab app ships such an id without the feature.Each call that moved is on the object
createIntegrationsreturned, and takes no pool:integrations.callIntegration(id, path, init)(MossServer.callIntegrationis gone),integrations.integrationByKey(key),integrations.integrationOfAction(id); and one is new,integrations.listIntegrations(). They work oncecreateServerhas answered. Before that the three that read rows reject with a sentence that says so.New from the entry:
createIntegrationsand the typesIntegrations,IntegrationsConfig,ExtensionConfig,ExtensionContextandRegisteredIntegration.app.actionsis what the app shipped, and stays it. Moss wrote the integrations' actions into that record; it no longer does. Code that read them there readsserver.actions().
Tide's hosting is tide's: the store and the driver ship from tide, and the write-fact bridge is a feature,
@niscorp/moss/tide.Moss held a Postgres store that implements tide's store contract, and a driver that paces tide. They are tide's storage and tide's pacing, and every moss app installed
@niscorp/tidebecause of them. Both are in tide now (the tide changeset), under the names they had, and each is called as it was:createTideStore(runtime.pool)takes the pool it took.The tables are the ones the database has.
TIDE_SEQUENCEis the same sequence under the same id,nisc.moss.tide, with the same migrations in the same order. A deployment that ran it finds nothing pending.@niscorp/tideis an optional peer of moss. An app that runs no tide does not install it, and nothing in moss's main entry imports it. An app that uses@niscorp/tide/postgreshas@niscorp/stratainstalled, which every moss app does.NiscApp.factsis gone: minting a write into tide is a feature. An app that did it creates the feature and names it in its manifest:import { createTideFacts } from '@niscorp/moss/tide'; const app = defineApp({ charter, actions, features: [createTideFacts({ tide: () => driver, identity, chain })], });tide,identityandchainare the three functionsfactsheld, under the names they had, and each is asked what it was asked. A manifest that still namesfactsis refused at boot:(the manifest): Unrecognized key: "facts".An app that does not name the feature mints nothing. No write becomes a fact, and a reflex with a
writetrigger is never woken by one.A write is told to the features that hear writes, in the order the app named them, and then to
reactions. That is the order it was told in while the bridge was part of the server.New from
@niscorp/moss/tide:createTideFacts, and the typeTideFactsConfig.
The client and terminal entries export what an app calls: six names leave.
@niscorp/moss/clientlosesSNAPSHOT_ELEMENT_IDand the typeWireTokenStore. The id is read byreadDocumentSnapshot()and written byrenderDocument(main entry): call those. The type isWireEnv['tokens'].@niscorp/moss/terminal/reactlosesregisterWireSlotsand the typeWireSlotOptions.reactTarget,renderSnapshot(/terminal/react/server) andinkTargetregister the wire slots on the registry they are handed: call one of those. A kit that wants its own element around a canvas wraps the slot moss registered, read back withregistry.get('CanvasSlot')once the target is made, as lyceum's does.@niscorp/moss/terminal/vuelosesregisterWireSlotsandterminalFrame:vueTargetandrenderSnapshot(/terminal/vue/server) are the two things they were for.- Nothing in this repository imported any of the six.
(2) THE MANIFEST
The manifest is parsed and frozen when it is handed over.
createServer(app, runtime)parses the manifest before anything else uses it, and a manifest that does not parse refuses the boot withThe manifest does not parse — refusing to serve:and one line for each place that does not fit (actions.home: Unrecognized key: "tittle",shell.inputs: expected a function). It used to be used as it stood: a definition nova refuses was found when a shell that holds it was built, for whoever connected first, and a seam that was not a function when it was first called.- An action, a fragment and a layout are read by nova's schemas, an entry's
dslandmutationby vex's, itsmappingby Prism's,grammarsby strata's. Every other data field is held to the shapeNiscAppgives it, and a code seam to being a function. - A field the type does not have is refused, on the manifest itself, on the shell or a page, on a canvas, a layout variant, an entry, a reaction,
identityandphraseKeys. The compiler already refuses one written in place; one that arrived through a spread or a cast used to be carried and ignored. An app that kept something of its own there keeps it beside the manifest. A component's contract (shell.components) and a resource written as an object may still carry more than moss names. - An app whose manifest is typed as
NiscApp, with no cast and no extra field on the way, has nothing to change for the parse. - Then
createServerfreezes the manifest, every level of it, in place. Once it has returned, an edit throws aTypeErrorwhere it is written:app.actions.x = …,delete app.actions.x,app.actions = …,app.charter.role = …,app.entries.push(…), a write to a definition, a layout node, a canvas or a variant. A function and an instance of a class (a zod schema a kit handed over as a component's props) are not frozen and not looked inside. BeforecreateServeris called nothing is frozen:defineAppreturns its argument, and an app may go on assembling it. - An edit to the charter, the behaviors or the layout variants used to be half served: it reached whoever was resolved next and nobody already resolved, and no check had seen it until a
refresh. An entry added toapp.entriesafter boot was never seeded. - What an app does instead. Actions that arrive while it runs are handed over as a set, with
server.replaceActions(next part). A charter, a layout variant, an entry, a behavior and the shell do not change while a server runs: they change in the manifest the next server is created with. - An object the app shares between its manifest and its own code is frozen for both: code that wrote to one writes to a copy of its own. The checks of the five lab apps (lyceum, lyra, atrium, relay, encore) ran against the frozen manifest and none of them hit one, in the app, in moss or in nova.
server.replaceActions(source, actions): an app hands over the actions it loads from rows.- New. An app that loads actions from its own tables hands them over:
server.replaceActions('bundles', actions). The name is the app's; handing the same name over again puts the new set in place of the old, so an action it no longer names leaves. The actions in force are what the app shipped, then each set. A set has one writer. Each feature the app names keeps a set of its own under its own name ('integrations'is the integrations feature's, read from its tables), and only that feature replaces it:server.replaceActionsthrows for a feature's name, saying whose the set is; and two features of one name refuse the boot. An app names its own sets anything else. - What the app would then consist of is verified first, and every definition of the set is read by nova's schema before that. Refused, the call throws and nothing changed: not the actions being served, not what any role holds, not the record that was handed in. The sentence is the one
refreshthrows, orRefresh refused — actions nova does not read:with the action and what is wrong with it. Accepted, it does whatrefreshdoes in the process it was called in: memos are dropped and every living shell adopts; and the set is frozen, so it is changed by handing over another of the same name. - The set is handed to one process, and no other process is handed it. The call moves the generation pointer, and every other process on the same database follows the pointer within one poll (
sessionRevalidateMs, 60 seconds when unset): it reads again each set of actions it has a loader for, runs the same check over what it would then serve, drops what it derived, and has its living shells adopt. That is all the pointer carries. A set that reached a process throughreplaceActionsalone is in that process's memory: every other process reaches the same generation and goes on serving the set of that name it was last handed itself, or none. - An app that runs several processes hands the set to each of them. A set handed to
server.replaceActionsis that process's. The app calls it in each of its processes itself, on a signal of its own; each call moves the pointer once more, and the others drop what they derived and adopt again. The integrations feature's set,'integrations', is the one that crosses processes: it is kept in rows and read through a loader within one poll. - Editing
app.actionsand callingrefresh()is gone. The edit throws, andrefreshships nothing: it verifies what is in force again, drops what was derived from it and has living shells adopt, which is what a changed role or install needs. - An app whose sets are
ActionDefinitions nova reads has nothing more to change. One that handed over a definition with a field nova does not have corrects the definition: it used to be served, and was found when a shell that holds it was built.
A server answers what somebody holds, which actions are in force, and what a set of roles may read.
- New on
MossServer:catalogOf(principal)answers{ ids, hash }from the actions in force;actions()is the actions in force, by id;policyForRoles(roles)is the vex policy a request by somebody holding exactly those roles is served under. There is no dry run ofreplaceActions(server.verifyActionsis not on the server):server.replaceActionsthrows with the reason when a set is refused and leaves what is in force unchanged, andverifyAppverifies a manifest without a server. - New in the main entry:
verifyApp(app, grants), the check boot runs for an app that is not booted. Its report is{ errors, said, charter }(its type isReturnType<typeof verifyApp>).errors[].checkis one of'actions','charter','reach','variants'.report.charteris whatverifyCharteranswered, closure issues included, andreport.errors.filter((error) => error.check === 'variants')is whatverifyVariantsreturned. - These are what the functions they replace should have been.
resolveCatalogreadassignmentsand nothing else, so in an app that resolves people throughidentityit answered aspublicfor everybody the assignments did not name; the server resolves who somebody is the way a request does.resolvePolicyForRolescompiled from whatever manifest and whatever list of grants it was handed, so a check that wrote the grants down itself read a policy no request was served under; the server compiles from the charter and thebehaviorsin force, over every table the database has. WhatpolicyForRolesanswers is compiled for the asking and is the caller's own: nothing done to it reaches a request. resolveRoles,resolvePolicyandresolveVariantsare gone too. Each readassignmentsand nothing else, so for an app that resolves people throughidentityeach answered aspublicfor everybody: a wrong answer, handed to whoever picked that way besideserver.identityandserver.policyForRoles. Who somebody is, is asked of the server, which resolves them the way a request does; a check that has no server boots one, as the checks of the lab apps do.- Code running in a session that wants what its own caller holds asks
GET /catalogover the session's wire, as before.
refreshruns the check boot runs.refresh()ran a shorter copy of the boot check: the charter and the layout variants. It is now the same verification as boot's, entries'reachincluded. It verifies what is in force, and what is in force was verified when it was taken, so an app sees no difference.- A boot that is refused prints the chains of steps that never end before it throws. It printed them only when the charter had passed.
- Where an app's entries are held, as before. In two places while a server runs. The running manifest holds the list the app shipped and is their source: the boot check reads it, and a shell is told of the reads on its screens from it. The list is copied once, at boot, into the cache vex replays from (
vex_cache, or the cacheruntime.cachehands over), and the cache is what runs them: a request's fingerprint resolves there, and the manifest is not asked. The cache also holds what the list does not: a querysession.answerIntentwrote while the app runs, and any row it held before this boot, because seeding writes each entry of the list and takes none out. None of that moved in this release, and an app has nothing to change. - A boot the check refuses has seeded no entry. The app's entries were written into
vex_cachebefore the check ran, so a manifest that was refused had already written its rows. They are seeded once the check has passed, by the same seeding, into the same table. A manifest that fails both the check and vex's lint of a write (Mutation seed "…" fails the authoring lint) is refused with the check's sentence, where it was refused with the lint's; corrected, the next boot says the other. An app that boots has nothing to change.
The generation counter is read and moved through vex, and a change another process made is followed, or tried again.
- Every process polls one row,
moss_generation, to learn that another changed what the app consists of. It was read and moved by statements sent to the pool: the last SQL in moss with no reason to be outside vex. It is two entries of moss's own now, replayed in process under a policy that admits that one table. No route serves them, and no charter has to grant them. A role granted a data wildcard still deniesmoss_generation.*(AGENTS.md, rule 10a). - A move is a compare and a set. Vex's write grammar sets a column to a value and reads none, so it cannot add one: the counter is read, and the next number is written where the counter is still what was read. Where it is not, another process moved it in that instant, and it is read again. A counter that could not be moved after five tries in a row is logged (
[moss:generation] could not move the pointer). A row somebody deleted used to be moved without a word, and nothing was moved. - The counter is moved through
runtime.db, the client every write is replayed on. It wasruntime.pool. An app whosedbandpoolreach one database, which is every app there is, sees no difference. No span is emitted for the counter's reads and moves, as none was. - When the pointer moves, a process reads again the sets of actions it has a loader for (the integrations feature's, kept in rows). A set it was handed with no loader stays what it was handed ("
server.replaceActions", above). When that reading failed (the database out of reach for a moment, a set this process refuses), the move was counted as seen all the same: the process went on serving what it had until the pointer moved again, and its log said the pointercould not be read, which it had been. A move is counted as seen once it has been followed. One that could not be is logged as that (the generation pointer moved (3 → 4) and this process could not follow it: …), and is tried again at the next poll (sessionRevalidateMs), and the one after, until it is. It is logged when it is news, not at every poll: when it first fails, again when the pointer has moved on or the reload fails another way, and once when it is followed (followed the generation pointer to 4, after 12 looks that failed). - A process that moves the pointer itself counted the number its own move landed on as seen. When another process had moved the pointer first and no poll had come between, what the other one changed was never read here. Its own move is counted as seen only when nothing else is waiting to be followed; otherwise the next poll follows both.- An app has nothing to change.
A page whose path names one parameter twice refuses the boot.
path: '/docs/:id/versions/:id'was accepted, and the canvas its parameters go to was handed oneid: the later segment's. The first was matched and lost.createServerrefuses it now:moss: page "…" names the parameter ":id" twice in "…" — give each parameter of a path its own name.- An app with such a path names the second parameter something else. No lab app has one.
(3) THE SESSION, AND WHO IS CALLING
FunctionSessioncarries noruntimeand nopolicy.- The session an app's
functionsandonSessionare handed loses two fields.runtimewas the whole environment: the database pool, the operator key, the signing seed.policywas the object the routes enforce for everybody holding those roles. Code that runs for one person was handed what reaches every person's rows, and the one object whose edit would change what the routes allow. A session now says who is calling (principal,roles,identity,actions), lendswire,grant,revoke,recordRun, and one call that is new. - New:
session.answerIntent(writers, asked), for an app that lets a model write a query.writersis vex's two seams,{ generateDsl, mapToShape };askedis{ intent, shape, context? }. The query is written, compiled and run under the policy and the scope values the routes serve the session's principal under, resolved when it is asked, and kept in the cache the routes replay from:answer.meta.cache.fingerprintreplays oversession.wireafterwards. It rejects with what vex throws (code: 'unsatisfiable','scope_denied'). Moss keeps one engine for eachwritersobject, so hand over the same object each time. - What an app calls instead.
- It built a second vex engine from
session.runtime.poolandsession.runtime.cacheand executed withscopePolicy: session.policy→await session.answerIntent({ generateDsl, mapToShape }, { intent, shape }). Moss builds that engine over its own adapter and cache, with the app'sbehaviorsandruntime.vexConfig, and hands neither out. - It read
session.policyto know what the caller may read (which feeds, which stored queries to offer) →server.policyForRoles(session.roles). - It read
session.runtimefor its own database (minting a session for somebody else, a sync, a raw statement of the deployment's own) → the runtime the app created and handed tocreateServer, closed over where the app builds its manifest. Moss does not hand it back.
- It built a second vex engine from
- A read or a write as the caller is
session.wire, as before.
The shell a session hands out refuses the nine members that change what a shell is made of.
registerAction,removeAction,registerFragment,addCanvas,removeCanvas,setCanvasLayout,setLayout,setPhrasesanddisposethrow onsession.shelland on theshellof whatserver.shells.session(…)answers. An action, a fragment, a canvas, a layout and the phrases reach a shell through moss, which verifies them and decides who holds them; added on the shell directly they are not verified, are in nobody's catalog and are gone when the shell is rebuilt. Reads, navigation,dispatch,publishand the observers work as before, and the type is still nova'sShell.- What an app writes instead. Code that does it anyway takes the whole shell, and says so in its imports:
import { unverifiedShell } from '@niscorp/moss/unverified', thenunverifiedShell(session).registerAction(definition).
Moss's stored sessions are what
session: 'sessions'verifies with, andmintSessionandrevokeAllForare for that deployment.- Of the stored credential the main entry keeps two calls,
mintSession(pool, principal, ttlMs)andrevokeAllFor(pool, principal).sessionOf,revokeSession,initSessionsandSESSIONS_SEQUENCEleft. The table is made bycreateServerand bymigrateTablesundersession: 'sessions', and only then; the lookup is that deployment's verifier. - An app whose
sessionis a function of its own that read moss's stored sessions (session: (token) => sessionOf(pool, token)with a check of its own around it, andinitSessions(pool)to make the table) has no call left to do that with, andserver.principalOf(token)is not one: it asks the app's own verifier, which would be asking itself. It chooses one of the two. Withsession: 'sessions', moss verifies, and the app's own check moves to where it belongs:revokeAllFor(pool, principal)at the moment somebody is to be signed out everywhere, oridentity.resolveanswering{ roles: ['public'], scope: {} }for somebody who may hold nothing. Or it keeps its verifier and its own credential with it: its own table or signed token, minted and revoked by the app, and neithermintSessionnorrevokeAllFor. No app in this repository did this.
The dev verifier is not exported, and a verifier of the app's own is named at boot.
devSessionis gone from the main entry. It trusts any token that carries asub, and passed assession: devSessionit booted without the linesession: 'dev-open'prints. An app that imported it setssession: 'dev-open', which is the same verifier and says so at boot. A check that wrapped it to make a token expire keeps a map of the tokens it issued instead.mintDevTokenstays.- A
sessionthat is a function prints one line at boot:[moss] session: a function the app supplied (name) decides who a token is.Nothing about the verifier changes, and an app has nothing to do.
The record
identity.resolveanswers is read by a schema.rolesis a list of strings,scopean object,installed(when there) a list of strings,tag(when there) a string. A record that does not fit is refused with a sentence that names the field, and nothing is held for that principal: a request whose principal resolves to such a record is answered500identity_failed(next part). It used to be carried on as it was, and failed later, somewhere else, or not at all.- What is inside
scopeis not looked at, and a field moss does not name stays on the record thescopehook is handed. - An app whose
resolvereturns anIdentityRecordhas nothing to change.
A failure to find out who is calling is
500 identity_failed, and is logged. It was401, and silent.- Every HTTP request is identified once, at the door. A token the verifier says is nobody's is a failed sign-in, and is answered
401invalid_token, as before. - Anything that threw while the server found out who is calling was answered
401no_identitytoo, with nothing in the log: the database being down, the app'sidentity.resolvethrowing, an identity record that does not parse. A client that drops its session on a401dropped it over a fault of the server's. It is answered500{ error: 'identity_failed', message: 'The server could not find out who is calling. Why is in its log, not here.' }now, and the error is logged. None of its text is sent. - The same answer where the throw used to reach Hono's own handler and came back as the text
Internal Server Error: asessionverifier that throws, and a feature's credential (an integration key) that cannot be read or whose principal cannot be resolved. no_identityis gone. An app whoseidentity.resolvethrows for a principal it does not know answers a record for nobody instead ({ roles: ['public'], scope: {} }), as lyra and lyceum do, or has itssessionverifier answernullfor the token. Code that matched on401no_identitymatches on500identity_failed.- The socket is as it was: a session that cannot be opened is
session_failedand a4500close.
runtime.identityMaxof zero or less refuses the boot.- With an
identityseam declared,identityMax: 0(or less) used to boot and then hang the process on the first sign-in: making room in a cache that has none never ended.createServernow refuses it with a sentence that names the setting. Give it 1 or more, or leave it unset for the default of 10,000.
An identity that is forgotten stays forgotten.
invalidateIdentity,invalidateTenantandrefreshforget what moss holds of a principal. A record that was being resolved while one of them ran used to be held when it landed, so a role change or an install made at that moment was not seen until the record expired by itself (sessionRevalidateMs). It is now handed to whoever was waiting for it and not held. An app has nothing to change.
What a fabric's transport hands over is read by a schema.
runtime.fabriccarries three signals between the processes of one deployment. What itssubscribehanded back was used as the type said it was: a message from another process, off a wire that is the host's, was switched on unread, and aprincipalthat was not a string reached the identity cache as it came. It is parsed now: the object moss published, with exactly its fields. What does not parse is dropped, and the first one is logged with what was wrong with it and nothing it carried ([moss:fabric] the transport handed over what is not a message…).- The applier
subscribeis handed takesunknownwhere it took aFabricMessage: a transport passes on whatJSON.parseanswered and casts nothing. A transport typed against the old applier still compiles. - A transport that hands the message over as it was published has nothing to change. One that handed over the text it received parses it first; one that added a field of its own to moss's message carries that field beside the message, not in it.
(4) INTEGRATIONS
Integrations read and write through vex, and a feature is handed no pool.
- Every statement integrations ran on
integrationsandintegration_actionsis a vex entry now, replayed in process. The audit counted 28 statements in moss with no reason to be outside vex, 24 of them on those two tables; none is left (the generation counter's four went last), andpnpm check:raw-sqlholds the count at zero. - The entries are the feature's own. They are seeded into a cache no route serves, so
/api/vexanswerscache_missfor one of them whatever the charter grants whoever asks, and they run under a policy that admits the feature's tables and nothing else. A data grant onintegrationsopens no approval and no registration: those stay behind the operator key. - A feature is handed no pool: it hands its reads and writes over as vex entries and replays them in process.
- A feature names the tables that are its own and the strata sequences that make them. The server migrates the sequences in the one ledgered run, after moss's own and before
runtime.tables, andmigrateTables(nisc migrate) runs them too. - A feature's own entries reach the tables it named on itself, and no other. They run outside the charter, under a policy that admits those tables alone, so which tables they are is read off the feature before it runs and is not something it says when it hands entries over. An entry that reads or writes any other table refuses the hand-over, and with it the boot:
moss: the feature "…" handed over an entry that reads or writes a table that is not its own: "…". What an app's table holds, a feature reads as the app does: through the app's entries, under the charter. runtime.vexConfig.entities, a list of the tables the app's engine is to know, says nothing about these entries: a feature's own, and the generation counter's (group 2), read the tables that are theirs off the database whatever the list names, and know no other. An app that sets the list has nothing to change.- No two features of an app share a name.
integrationsandintegration_actionsare made bynisc.moss, where every deployment's ledger has them, and are the integrations feature's: it names both. - Each feature is started with seams made for it, and what they reach is that feature's. Every feature was handed one and the same object.
- A telemetry sink is told of the registry's reads and writes. Each replay of one of the feature's own entries emits a
vex.executespan, as a request's replay does and oneexecuteAsmakes: itsfingerprintis the entry's (integrations/list,integrations/by-key, …) andhasPrincipalisfalse. The statements they replaced emitted none. Counted in one process, by the entries each path replays: an operator's list of integrations is two spans, a request that carries an integration key is one, and the boot of an app that names the feature is one. A sink that countsvex.executespans, or alerts on their rate, tells these apart by afingerprintthat starts withintegrations/. The generation counter's reads and moves emit none. - What a caller sees:
last_import_atis the server's clock where it was the database's. Nothing to change.
A bundle is nisc's own fields and
extension.A bundle had sixteen top-level fields. Five are nisc's own artifacts, and moss reads them as it did:
integration,grants,grammar,actions,phrasebook. Eleven were one product's vocabulary (meta,capabilities,offers,needs,attachments,placements,settings,configuration,documents,assistants) or served it (frames, next part), and are no field of a bundle. A package does not own a closed vocabulary for what is open: the app that has a store, documents and assistants defines them.One field is new:
extension. Whatever the host app's own vocabulary is; moss does not read inside it. The app says what it may be when it creates the feature:createIntegrations({ integrationActor, extension: { schema, // a Zod schema: what a bundle's `extension` may be check, // (extension, bundle) => string[]: the reasons the app refuses it prepare, // (extension, bundle) => extension: once at import, before the row is written contract, // a plain value the app tells vendors about its vocabulary }, });Only
schemais required.checkandprepareare handed what moss knows of the bundle:{ integration, url, actions, fingerprints, layoutMisfits }(its id, where its service lives, its action definitions, the fingerprints the app serves, and the host's answer about a layout).createIntegrationsis generic in what the schema answers, so what the app reads back is typed by its own schema.A top-level key that is not a field of a bundle refuses it, with a sentence that names the key:
"meta" is not a field of a bundle. What belongs to the host app’s own vocabulary goes underextension.Every vendor's bundle changes shape: what it said in the ten fields it says underextension, in the shape its host's schema reads. An app that supplies noextensiontakes no bundle that carries one.Intake holds a bundle to what it did for the fields that stay (its actions' namespace, endpoints, layouts and chains of steps), then reads
extensionwith the app's schema and runs the app'scheck. Reasons from all of them are reported together, as before.integrations.listIntegrations()is the one read, in place oflistAttachmentsandlistPlacements: every registered integration as{ id, status, url, extension }, the extension read by the app's schema. A host builds its placements, its listing and anything else from that. An app's own vex entry readsintegrations.extensionas any column.The operator's list (
GET /operator/integrations) answersid,url,status,requestedActions,requestedData,approvedData,extension,lastImportAt,lastError,actionCount.title,tagline,adds,settingsAction,offers,needs,capabilities,configuration,documentsandassistantsare gone from it: a pane that showed them reads them offextension.The contract (
GET /api/integrations/contract) no longer carriesattachableandmenuSlots, and its Markdown has no section on where screens appear. It carries the app'sextension.contractunderextension, as the app handed it over.A preview is no longer a path the proxy forwards. The reach of an integration is what its actions' endpoints declare. An endpoint a host's own vocabulary names (the preview of an attachment) the host calls itself, with
integrations.callIntegration(id, path, { principal, body }), which signs the call as the person looking and is not held to reach.
Frames are gone.
- A frame was a vendor's own page shown in an iframe through moss: the
framesfield of a bundle,POST /api/integrations/frame,GET /integrations/:id/frame/:tokenandPOST /integrations/:id/frame/:token/:call. All four are gone, and/integrations/<id>/frame/is a path of the integration's like any other. - An app that wants a vendor's page inside its UI mounts a route of its own (
routes(server)innisc.config.ts), decides there who may open it, and fetches the page withintegrations.callIntegration. Moss does not need to know.
What a host with a store takes over.
Everything below left moss. The last commit that still has all of it is
41fc8942(packages/moss/src/integrations/:bundle.ts,intake.ts,press.ts,placements.ts,reach.ts,registry.ts,entries.ts,contract.ts), for a host that lifts it into its ownextension.- Bundle fields, with their schemas (
bundle.ts):meta(title,tagline,description,story,highlights,press);capabilities({ id, title }, the id rule);offers;needs;attachments(action id → host action, or{ to, preview });placements(action id → menu hub);settings;configuration(the four closed kindstoggle,number,pick,line, each with a default, and their coherence rules);documents(the field vocabulary, section types with fragments, the fixed and open section models, regions, checks, the publish path);assistants(instructions, grounding, tools, blocks with fragments, starters, what it applies to). - Options of
createIntegrations:attachable,menuSlots,assistantTools,publishChecks,editorRegions,placementNames,storePress,pressMaxBytes. They are the host's own values now, read by its owncheckandprepare. - On what
createIntegrationsreturns:listAttachments(hostAction)andlistPlacements(). A host derives both fromlistIntegrations(). - Checks (
intake.ts, the host'schecknow): an attachment names an action of the bundle and a host action that offers a seat, and its preview is under the integration's own prefix; a placement names an action of the bundle and a hub the host offers; a press path is under the integration's own prefix and not underhook/; no two capabilities share an id;settingsis an action of the bundle; a document's id is its own, its capability is one the bundle declares, its publish path is under the integration's own prefix, its regions are actions of the bundle or regions the host offers, its checks are ones the host offers, its section types and field keys are not repeated, apickhas two options, alistis in no section, its fragments fit the kit, a fixed section's type is in the registry and what it extends is a sibling; an assistant's id is its own, it applies to a document or a screen of the bundle, its grounding names fingerprints the app serves, its tools are ones the host offers, and its blocks hold the same rules as a section's fields and fragments.bundle.actions,bundle.fingerprintsandbundle.layoutMisfitsare what acheckneeds of moss for them. - Helpers:
copyPress(press.ts: each press image fetched once from the vendor's service with a timeout and a bound on its bytes, refused unless it is an image, handed tostorePress, the bundle refused whole when one fails), which is a host'spreparenow; anddescribePlacements(placements.ts: the sentence that says what appears where, in the host's words). - The proxy's reach for a preview (
reach.ts): a preview was forwarded for whoever held the attached action. A host makes the call itself, withcallIntegration. - Columns: of
integrations,title,tagline,description,story,highlights,press,adds,settings_action,offers,needs,capabilities,configuration,documents,assistantsandframes; ofintegration_actions,attach_to,previewandplace_in. One column holds what replaced them,integrations.extension. - The contract's
attachableandmenuSlots, and the Markdown that explained them: a host says them inextension.contract.
Registering an integration is one transaction, and an approval is verified before it is written.
- A bundle's row, its actions and the hash of its key landed as separate statements. One that failed part-way left a row that said one thing over actions that said another, or over none. They are one transaction now: a bundle lands whole, or the import before it is still there, whole.
runtime.dbhas to havetransaction. Landing a bundle is a vex batch, and a batch needs a client that can transact, as any batch an app writes does. Adbthat cannot answers500(Batch mutations require a transactional client.) to a registration.createPglitePoolhas one; a pool an app wraps itself givestransactionas it givesquery.- Approving wrote the approval, handed the actions to the running manifest, and wrote the row back when that was refused. A process that died between the two writes left an approved row the next boot refused. The route now asks first whether the app could be served with what the integration shipped, and writes only then. A refused approval answers the same
422with the same reason. - An approval that is refused when it is asked about no longer leaves its reason in
last_error(lastErrorin the operator's list): nothing is written. A pane that read why an approval was refused off the list reads it off the answer. - Asking first makes a refusal after the write rare, and something else can still change what the app consists of between the question and the hand-over. An approval that was written and that the app then does not take is taken back, as it always was: the row is pending again with the reason in
last_error, and the answer is the same422. - Importing a bundle again for an approved integration asks the same question, and was answered by putting the bundle's actions into the running manifest and taking them out again when the check threw: a refusal dropped every identity record and every compiled policy twice and moved the generation pointer twice, in every process. The bundle is verified as a candidate now, and the running app is not touched. The answer is the same
422, and an import that is accepted moves the pointer once.
Two registrations of one new integration at once are answered one key, and it is the key that works.
- Registering asks how the integration stands, mints a key when it holds none, and lands the bundle with the key's hash. Two registrations of one new integration in the same moment (an operator's tool that retries while the first call is still out, two operators) were both told it holds no key, both minted one, and the later landing wrote its hash over the earlier one's. Both were answered
200with akey, and one of the two keys was nobody's from the start. Found by running the routes from two connections of a real Postgres: 200 rounds, 200 dead keys. - The key's hash is written where the row holds none, in the same transaction as the bundle, and the registration reads off what was written whether its key is the one that was kept. The one whose key was kept is answered it. The other is answered as any import after the first is:
200, its bundle landed, and nokey. - A tool that registers reads
keyfrom the answer that carries one, as it did, and does not expect one from every answer to a first registration it sent twice. Nothing else to change.
No call out to an integration waits for ever.
- New on
createIntegrations:outboundTimeoutMs, default30000(nova's default wait for an endpoint's reply). It is a whole number of milliseconds from 1 to 2^31 − 1; anything else (Infinity,0, a fraction) throws wherecreateIntegrationsis called. - The sentence put under a call that failed with none names no product. It was
The <id> add-on is not answering right now. The rest of the studio is unaffected.It isThe <id> integration is not answering right now. The rest of the app is unaffected.A check that reads the old sentence off a screen reads the new one. - The
integration.callspan says which integration underintegration. The attribute wasaddon. A sink or a dashboard that readsattributes.addonreadsattributes.integration. - Every call out is given up on when it has taken
outboundTimeoutMs, its answer and its body together: a screen's call through the proxy, a vendor's webhook,callIntegration, the bundle fetched at registration, and the operator's probe. It reads as a service that is unreachable:502from the proxy, the webhook and a registration whose bundle did not arrive;callIntegrationrejects; the probe answersstatus: 0. None had a timeout, and a service that never answered held the call for as long as the process lived. - An integration that takes longer than thirty seconds to answer, or whose answer streams for longer, is cut. Set
outboundTimeoutMsto what the slowest one needs. A longertimeoutMson the integration's own action is not enough by itself: the shorter of the two ends the call.
Two migrations of
nisc.mossarrive with this release, and one takes columns out.nisc.mossgains its third migration (next part) and its fourth. The fourth addsintegrations.extensionand drops the columns that held one product's vocabulary:title,tagline,description,story,highlights,press,adds,settings_action,offers,needs,capabilities,configuration,documents,assistantsandframesofintegrations;attach_to,previewandplace_inofintegration_actions.- A deployment that boots with
migrations: 'verify'runsnisc migratebefore it starts on this release, as for any release that adds a migration. With the default,'apply', the server applies both when it boots. - An integration registered before keeps its row, its key, its grants, its reach, its phrases and its actions, and loses what the dropped columns held until it is imported again in the new shape (
POST /operator/integrationswith the same{ id, url }): its listing, where its screens were placed, its settings screen. Itsextensionis null until then, andlistIntegrations()answers itundefined. - An app's own vex entry that selects one of the dropped columns (a store that read
integrations.title) selectsintegrations.extensionand reads the value out of it.
An integration's id is a name, and the hash of its key is its own.
- An id is lower-case letters, digits and hyphens, and starts with a letter: what the paths (
/integrations/<id>/…) and the action names (ext.<audience>.<id>.<name>) already assumed.POST /operator/integrationswith any other id answers400and fetches nothing; a bundle that calls itself anything else is refused at intake. - An integration that was registered earlier under an id that does not fit goes on being served. It cannot be imported again under that id: remove it and register it under one that fits.
- The third migration of
nisc.mossis a unique index onintegrations.key_hashwhere it is not null. No two integrations hold the hash of one key. Moss mints a key from 32 random bytes and never wrote one hash to two rows; two rows somebody gave the same hash by hand refuse the migration, and one of them is cleared first.
A signing seed that cannot be a key refuses the boot.
- The signing seed is the integrations feature's own setting:
createIntegrations({ signingSeed }). It wasruntime.signingSeed, the deployment's, and reached the feature as a seam, so every feature an app named was handed the private half of the key assertions are signed with.NiscRuntimehas nosigningSeedany more, and nothing a feature is started with carries one. An app that set it moves the value from the runtime to where it creates the feature; a runtime that still names it is refused by the compiler where it is written in place, and is otherwise carried and ignored, which signs with a key made at boot. Lyra is changed here:LYRA_SIGNING_SEEDis read where the feature is created. - The seed makes the keypair assertions are signed with the same in every process and across restarts. It is 32 bytes, as base64. One that decoded to fewer was dropped without a word and a random key was generated in its place: two processes given the same seed signed with different keys, each refused what the other had signed, and an integration holding the public half of one refused every call from the other. Nothing said why.
- A seed that is given and decodes to fewer than 32 bytes throws now, when the signer is made: for a server that names the integrations feature, while it boots; for
createAssertionSigner(seed)called directly, at the call.moss: the signing seed decodes to 13 bytes, and a signing key is 32. Give 32 random bytes as base64 (\signingSeed` of `createIntegrations`), or give none: …` The sentence says the seed's length and never the seed. - No seed is as it was: absent, or the empty string, generates a key that is kept nowhere. A seed longer than 32 bytes is its first 32, as it was.
- An app whose seed is shorter than 32 bytes gives one that is 32 (
node -e "console.log(require('node:crypto').randomBytes(32).toString('base64'))"), or removes it. Such an app was already signing with a random key: every integration that holds its public key reads the new one fromGET /api/integrations/verify-key, once, and it stays put from then on.
The contract lists the fingerprints its caller may replay.
GET /api/integrations/contractlisted every fingerprint the app serves underfingerprints, whoever asked: it compiled the caller's policy and then kept every entry. It lists the reads and writes the caller's own policy can touch now, as vex's discovery does for that policy.- Asked with no credential, it lists what
publicmay replay. An integration's author asks with the integration's key andx-nisc-acts-for, and reads what the integration's actor reaches. - Intake is as it was: it admits an endpoint that names any fingerprint the app serves. Whether a call is served was, and is, decided by the caller's policy when it is made.
Three answers of the integration routes differ at the edges.
- A stored action definition that nova's schema refuses is left out of what is served, with a line in the log. It used to be served as it stood.
- A route body that is JSON and not an object (
null) reads as an empty body. The route used to answer500. - A component whose
propsSchemacannot convert itself to JSON Schema is listed in the contract by its name and description. The contract route used to answer500.
(5) A WRITE, AND TIDE
A lane that throws on a write no longer keeps the other from hearing of it.
- A committed write is told to the features that hear writes (
createTideFacts) and then to the reactions (app.reactions). When the fact lane threw (itstide(),identity(scope)orchainthrowing on a scope the app did not expect) no reaction heard of that write, and the throw went to vex. Each is told on its own now: the throw is logged ([moss:writes] …) and the reactions run. - A reaction that throws was already kept from the next reaction, and still is. An app has nothing to change.
x-tide-depthis read as digits, not withNumber().- A write through vex may name its place in a chain of effects in two headers,
x-tide-causeandx-tide-depth, which thechaingate ofcreateTideFactsthen decides whether to believe. The feature reads them itself; the core's routes used to. The depth was read withNumber(): an empty header was depth0, the start of a chain, and so0x2was 2,1e1was 10 and thirty nines a depth no whole number holds. A depth is digits and nothing else now, and a number that is exact. Anything else is no position:chainis not asked and the write is heard with none. - A caller that writes the header as
String(depth)has nothing to change; lyra's does.
(6) THE SOCKET AND THE SHELLS
A message on the socket is read whole, by a schema, or not at all.
- The server parses what a terminal sends. A message with a field the protocol does not name used to be routed with that field ignored; it is now answered
invalid_messageand nothing of it is acted on. - A terminal built on
@niscorp/moss/clientsends no such message, and has nothing to change. A terminal written by hand sends exactly one of{ type: 'event', canvas, event },{ type: 'publish', channel, payload? },{ type: 'popTo', canvas, instance },{ type: 'back' },{ type: 'resync' },{ type: 'reset' }, and nothing beside those fields. invalid_messageis one sentence for every message that cannot be read. An event whoseeventis not an object used to get a sentence of its own, with itscanvason the reply; it gets the same answer as the rest.- The terminal (
@niscorp/moss/client) parses what the server sends, and the screen a drawn page carries. A message it cannot read is named by its type in the console and dropped: a tree holding something that is not one of nova's four nodes, or a node with a field nova's node does not have, used to be handed to the kit and is now not shown. An app has nothing to change. readDocumentSnapshotanswersundefinedfor a snapshot that does not parse whole. It used to drop aseed,pathorliveof the wrong type and start from the rest.- The terminal now runs zod in the page. Zod was already a peer every app installs; a page whose content security policy forbids
evalmay see zod's one probe for it reported, and setsz.config({ jitless: true })in its entry to skip the probe.
The address of an upgrade is read by a schema, and a protocol is digits.
- What a terminal writes in the address it connects to (
protocol,key,seed,path,delta,sealed,leave) was read where each was used, each with a test of its own. One schema reads them now, where the handshake begins. What each accepts is what it accepted, with one difference. protocolwas read withNumber():0x2,2.0,2e0and2were all protocol 2, and a terminal that wrote one was served. A protocol is digits and nothing else. Anything else is answeredclient_too_oldand closed4426, asbananaalways was.- A terminal built on
@niscorp/moss/clientwrites the number, and has nothing to change.
An event a terminal sends is read by nova's schema before a shell is handed it.
- The socket holds
eventto being an object and no more; the shell it is dispatched into took whatever arrived. It is now parsed by nova'sNovaEventSchemafirst: one ofui:click,ui:submit,ui:input,ui:focus,ui:blur,ui:model,ui:key,ui:drop, withref,payload,origin(andkeyonui:key) and no other field. One that does not parse is dropped, as an event naming an instance that is not on its canvas is. - A terminal built on
@niscorp/moss/clientand one of nova's adapters sends nothing else, and has nothing to change. A terminal written by hand that added a field of its own to an event, or sent a type nova does not define, used to have it dispatched; it sends nova's event. - An event tagged with a canvas the shell does not have is dropped. A terminal tags an event with the canvas it drew it in, and the shell holds what the event says of itself to that canvas. For a canvas id the shell does not have there was nothing to hold it to, so the event was dispatched with no origin, which nova delivers to every instance of the shell: one message naming a canvas that is not there pressed a button on every screen that had one of that
ref. The same holds forsession.dispatch(canvas, event)called in process. A terminal built on@niscorp/moss/clienthas nothing to change. One written by hand that named a canvas of its own invention, to reach every screen at once, tags an event with the canvas it came from; and to tell every screen something, it publishes on a channel ({ type: 'publish', channel, payload? }), which every action that listens hears.
Shells for nobody signed in have a limit:
runtime.anonymousShellMax, default 1,000.- Somebody signed in has one shell, however many terminals they open. Nobody signed in has one for each connection, and opening a connection takes no account and no token: nothing limited how many were alive, so the memory of the process was anybody's to fill.
- New:
runtime.anonymousShellMax, how many shells for nobody may be alive at once. A connection over it is sent{ type: 'error', code: 'at_capacity', message: 'This server is serving as many terminals with nobody signed in as it is set to. Try again in a moment.' }and closed with the new close code4503. No shell is built for it. Somebody signed in is served whatever the count is. - The default is 1,000, from a measurement. Lyceum's real boot, 2,000 connections for nobody opened through
server.socketand held, the heap read after a forced collection before and after: 52.5 KB of heap for each (about 110 KB resident); 26.7 KB for an app of one canvas and one action. The Node transport holds about 260 KB resident more for each connection while compression is on. A thousand fit a process given 512 MB. - An app with a public screen that more than a thousand visitors have open at once sets
anonymousShellMaxto what its memory carries (Infinityis no limit, as before). A page of the manifest that is not live opens no socket and takes no place. - It is one count for everybody with nobody signed in, in one process, and it bounds memory. It is not a limit for each visitor: one client that holds that many connections has every place, and every other visitor with nobody signed in is refused until one closes. Moss does not know a visitor's address (behind a proxy it is the proxy's), so a deployment that faces the internet limits the connections one address may hold at its proxy or load balancer, on
/socket.DOCS.mdsays this under the setting. - A terminal built on
@niscorp/moss/clienthas nothing to change: it logs theerror, and reconnects with its usual backoff, as after any close that is not4401or4403. The backoff grows for as long as it is refused ("A connection the server refuses is tried again more slowly each time", in group 8). A terminal written by hand treats4503as a close to retry later, and later each time, and keeps its token.
A failure on the socket stays one terminal's, and its text stays on the server.
- A message the server reads and then throws on used to let the throw out into the transport's listener, where Node ends the process and every session with it. It is now logged, and the terminal is sent an
errorwith the codemessage_failed; the connection stays open. - A session the server could not open used to send the terminal the error's own text, which can name a host, a table or a database user. The
session_failederror now says only that the session was refused, and the error is in the server's log. It still names the definitions that failed validation, when that is the cause. - A handshake that failed (the verifier threw, what somebody holds could not be asked, their shell could not be built) emitted no span: with the verifier down, upgrades did not turn into errors on whatever reads
runtime.telemetry, they stopped being counted. It emits onesocket.upgradespan now,status: 'error', withhasPrincipalas far as the handshake got. A sink that countssocket.upgradespans by status gains a value. - An app has nothing to change.
A living shell holds what its principal holds now, and nothing that left.
- When what the app consists of changes (
replaceActions,refresh, an integration approved or removed, a change another process made), every living shell adopts. It used to register what was held and never take anything away: an action that was removed, or that its principal was no longer granted, stayed mounted and pushable in every shell that had it, until that shell was rebuilt. It is now removed from the shell: its instances are unmounted, it leaves the historybackwalks, and a push of it fails as for any action the principal does not hold. - Every living shell is every one the server built that has not ended: the shell the app keeps for each signed-in person, the shell built for each connection of nobody signed in, and the shell a page builds for each connection, signed in or not. The last two used to keep what they were built with for as long as they lived, so a page somebody had open, and a visitor's screen, went on offering an action that had left. A change reaches every living shell of every host: the app's own, and each page's. A shell built to draw one document (
snapshot) is read and ended, and adopts nothing. - A host holds a shell that is not kept until it ends, which is how a change reaches it. It ends when its last terminal leaves, and the host holds nothing of it from then on. The socket does that for every connection it opens. Code that opens such a session in process and attaches nothing (
server.shells.session(null, null),server.page(path).host.session(…)in a check or a tool) ends it withsession.detach(connection), as the socket does for a connection that closed early; left alone, it used to be collected once nothing referenced it, and is now held for as long as the server is. A check that runs and exits has nothing to change. server.invalidateIdentity(principal)resets every living shell of that principal: the one the app keeps for them, and the shell a page built for each connection of theirs. It reset the kept one alone, so a page they had open went on as who they had been.reset(principal)on a page's host answeredfalseand did nothing; it rebuilds each of their shells on that host, carrying its terminal across, and answerstrue.- A shell whose principal could not be resolved during an adopt used to end the walk, and every shell after it kept the old application. It is logged and keeps what it held; the rest adopt.
- A shell that was being built while the app changed adopts when its build lands, and so does one a reset was replacing: the replacement is built from what its principal held when the reset began, and used to take the shell's place holding that. After two changes a shell holds what the later one left its principal with, whichever of the two is answered first. A change waits for no other change and for no build: a principal whose shell never finishes building (an
inputsthat never answers) keeps nobody else from adopting, as before. - Each living shell adopts on its own. After a change the living shells were asked one after another what their principal holds now, each after the one before it had been answered, as in 0.4.0: a principal for whom that is never answered (an app's
identitythat hangs for them) held every shell after theirs for that change. No shell waits for another now. What an app notices: a change asks about every living shell in the same moment, so an app'sidentity, and the read of its assignments, run for every signed-in principal whose record is not held at once, where they ran one after another. Who a principal is, is asked once for all of their shells and remembered, as before. - An action a living shell gains may mention a component no layout mentioned before: it is registered when the shell adopts. The component names a shell is built with are read off the actions in force each time they change, and were read once, when the server started.
- An app has nothing to change. One that relied on a revoked screen staying open until the next sign-in no longer sees it.
A shell host has no
adoptand nostop: both are the server's.ShellHost(server.shells, andserver.page(path).host) loses two members.adopt()brought the living shells of that one host level with what their principals hold;server.refresh()does it for every host, the app's and each page's, so the member was a second way that left the other hosts on the old application.stop()stopped one host's idle sweep and was kept "for hosts that outlive their server"; a host is made by a server and by nothing else now, andserver.close()stops every host's sweep with everything else the server started.- What an app writes instead.
server.shells.adopt()→server.refresh(), which also verifies what is in force again, drops what was derived from it, and tells the other processes.server.shells.stop()→server.close(), which also stops the socket's revalidation, the identity cache's sweep and the generation poll: a check that calledserver.socket.stop()andserver.shells?.stop()so that its process could end callsserver.close()once (encore's boot and relay's expiry check are changed here).
A nudge reaches every living shell of a principal, and so does what a reaction delivers.
server.nudge(principal, channel)and a reaction'sdeliver(principal, channel, payload?)published onto the shell the app keeps for that principal, and onto no other: an action on a page they had open, listening on that channel, heard nothing. Both reach every living shell of theirs now: the kept one, and the shell a page built for each connection they have to it. Between processes it is as it was: every other process is told the channel, with no payload, and tells the shells it holds.- What each is for.
server.nudgetells somebody's screens that something changed, in whichever host and whichever process they live; it carries no payload and answers nothing.server.shells.deliver(principal, channel, payload?)puts a message with a payload into the shell the app's own host keeps for somebody, in this process alone, and answers whether there was one; it is as it was. Where code calledserver.shells.deliver(principal, channel)with no payload to say "this changed",server.nudge(principal, channel)says it to every screen they have, and to every process. deliveron a page's host (server.page(path).host.deliver(…)) answeredfalseand did nothing, because a page's host keeps no shell. It publishes into each shell that host built for the principal, and answers whether there was one.- An app has nothing to change. A page's action that listens on a channel the app nudges, or a reaction delivers on, now hears it.
One shell for a principal, one reset of it at a time, and the canvases it has are its own.
- Two first arrivals for one principal in the same moment (a drawn page's read and its socket, two tabs) each built a shell. The second took the first's place and the first was never disposed: its
functionsandonSessioncode ran for as long as the process did. One is built, and both are handed it.functionsandonSessionare called once for it. - A build that takes longer than
runtime.endpointTimeoutMsfails (30 seconds when unset). The setting bounded a screen's endpoint call, and bounds the build of a shell too. A build runs the app's code:identity, the shell'sinputs,phrases. Where one of those never answered for one call, the build never ended. Now it fails when the time is up: a terminal that waited on it is told and its socket closes4500, a page that was being drawn for them is served undrawn, and the next connection builds afresh. If the app's code answers after that, the build stops there: none offunctions,onSessionandseedsis called for it, and no shell is made. What an app notices: an app whoseidentity,inputsorphrasestakes longer than the timeout for somebody gets no shell for them, where it got one late. It raisesruntime.endpointTimeoutMs, which is also how long a screen's endpoint call waits. - Resets that overlapped (
server.invalidateIdentitytwice, or with a terminal's ownreset) could leave every terminal attached to a shell that was no longer the principal's: nothing they pressed arrived. A shell's resets run in turn. A reset whose build throws or runs out of time is logged and leaves the shell that stood, and the reset after it goes ahead. - A page's shell that was reset lost the parameters of its path and the instance ids its page was drawn with. It is rebuilt with what it was opened with.
- A canvas a shell gained while it ran (
shell.addCanvas, or a push to a canvas its manifest does not list) was never sent to a terminal: the host walked the manifest's list. It is rendered, sent, attached to and read by a snapshot like the ones the shell was built with. A canvas that was removed is sent empty once, and no longer handed to a terminal that attaches later. - An app has nothing to change.
A shell is attached to what it uses of a connection.
server.shells.session(…)answers a session whoseattach,detachandresynctook the transport'sConnection. They take what a shell uses of one:{ send, close, seal? }. AConnectionis one of those, so a transport and a check that pass a connection have nothing to change. An object literal written in place withonMessageoronClosebeside them is refused by the compiler, as any literal with a member its type does not have: the shell never called either, and they are dropped (encore's check is changed here).
A connection that closed before its session was ready leaves nothing attached, and a live connection's credential is asked about by one pass at a time.
- Opening a session waits twice: for what somebody holds, then for their shell to be built. A terminal that left in between (a tab closed while its shell was built, a reconnect that gave up) was attached to the shell all the same, and on Node nothing detached it afterwards:
wstells a close to whoever is listening when it happens, and the socket started listening after the second wait. A signed-in person's shell then counted a terminal that was not there and was never swept as idle (runtime.shellIdleMs); a shell built for nobody was never disposed. The close is heard before either wait now. A connection that closed while its catalog was asked is not handed to a shell host; one that closed while its shell was built is not attached, and the shell idles, or is disposed, at once. - A transport's
onCloseis called once for a connection, where it was called up to three times. - Every
sessionRevalidateMsthe socket askssessionabout each signed-in connection, one after another. The timer started the next pass whether or not that one had finished, so a verifier that took longer than the interval was asked by two passes at once, then three. A pass that is due while one is still asking is skipped now. - A verifier that throws leaves the connection open, as before. That it threw was dropped without a word; it is logged, once for a pass, with how many connections could not be asked about and the first error.
- A connection whose transport throws when it is told its session expired stayed in the list, so every later pass threw at the same place: no connection after it was asked about again, and the throw was an unhandled rejection. It is forgotten first, closed even if it cannot be told, and what it threw is logged.
- An app and a transport have nothing to change.
(7) PAGES, AND THE NODE ENTRY
A document's
rootis any empty element, and one with no place for a screen is not reported as drawn.renderDocumentput the screen into the root by looking for></div>in it. Arootthat was another element (<main id="app"></main>) was written back as it was, with the snapshot beside it and no screen in it, and the page was reporteddrawn: true: the terminal started from a screen that was not in the page. The screen goes inside any root that is an empty element now, an opening tag and at once its closing tag.- A
rootwith no inside (an opening tag alone, an element that holds something, one closed in its own tag) is answered as a template that does not hold the root always was: the template as it is,drawn: false, and one line in the log that names the root. - An app that passes no
root, or one that is an emptydiv, sees no difference.
DocumentConfig.serveris the three things a document asks.renderDocumentandexportDocumentstook aMossServerand calledprincipalOf,pageandshellson it.serveris typed as those three now, and of each shell host assnapshotalone. AMossServeris one of them: a caller that passes its server has nothing to change, and a check that stood a server up to draw a page can hand over the three.
servestops what it started.serve(app, runtime)(@niscorp/moss/node) makes an HTTP listener and a websocket transport and returns the server and neither of them.server.close()stopped moss's timers and left the port bound and every terminal connected, and nothing else could reach either.close()on the serverservereturns now stops all three: moss's own timers, then every connection (closed1001), then the listener.attachSocket(…)answers{ close }where it answered nothing:close()ends every connection the transport opened (1001) and takes no more. A host that ignores what it answers has nothing to change.- An app that called
server.close()on a server fromserveand went on using the port has to stop doing one of the two.createServer's owncloseis as it was.
A boot that fails stops what it started.
createServerstarts timers before it has a server to return: the identity cache's sweep, and one idle sweep for the app's shell host and each page's. When a step after them refused the boot (a feature that throws when it is started, a page whose path the router refuses), they went on running, and nothing was returned to stop them by. Every timer isunref'd, so a process that exits on a failed boot never saw it; a process that boots again in place (the dev plugin on every edit, a host that retries) kept two more intervals, and what they hold, for each failed boot.- A boot that throws stops everything it had started, and then throws what it threw.
close()on a server that did boot runs the same list. - An app has nothing to change.
An interval Node cannot hold is off, and an operator gate that returns nothing goes on to the key check.
runtime.sessionRevalidateMsis handed to a timer in two places: the poll of the generation pointer, and the pass that asks about each live connection's session. Node holds a timer's delay in 31 bits and cuts a longer one to 1 ms. SoInfinitymade the pointer be read without pause, and an interval above 2^31 − 1 ms (24.8 days) did that to both. Either is off now, as0is. A deployment that setInfinityto mean "never" gets never.runtime.operatorGatemay return nothing: its type says so, and it was documented as falling through to moss's key check. A gate that returned nothing without callingnext()was answered500. It goes on to the key check now. A gate that returns aResponserefuses, and one that awaitsnext()sees the outcome, as before.- An app has nothing to change.
The two settings of the transport hold wherever a server's socket is attached:
runtime.socketMaxMessageBytes,runtime.socketCompression.- New:
runtime.socketMaxMessageBytes, the largest message a connection may send. It could be said toattachSocket(maxMessageBytes) and not to the runtime. Unset, the limit is the transport's 256 KiB, as before. runtime.socketCompressionwas read byserveand by nothing else:nisc startand the dev plugin attach the socket themselves and are not handed the runtime, so under those two a deployment that turned compression off was served compressed. The server reads both settings now, once, and the socket it hands out (server.socket) carries them to whichever transport is handed it. They hold underserve,nisc start, the dev plugin, and a host that callsattachSocket(httpServer, server.socket).- A deployment that set
socketCompressionand runs undernisc startornisc devgets what it set, where it got compression on. Nothing to change, unless the setting was left there by mistake. attachSocket'soptionsare for a host that means otherwise, and for an accept of its own that says nothing: a setting named there wins, and one that is not named stays what the socket says. A host that passed{ compression: runtime.socketCompression }itself deletes the argument.- In the dev plugin, the connections of an app that an edit replaced are closed (
1001). The plugin kept one transport for as long as vite ran, and a terminal that was connected when an edit re-booted the app stayed attached to the server that had been let go. There is one transport for each app that is booted now, set as that app's runtime says and retired with it: a page is reloaded as before, and a terminal that is not a page (a console) connects again, to the app that is booted. An upgrade that meets an app that did not boot is answered503, where it was opened and closed with1011.
The dev sign-in answers this machine only.
/dev/as/<who>(the dev plugin'ssignIn,dev.signIninnisc.config.ts) puts a session for whoever the address names in the browser that asks. It asked nothing about where the request came from: with vite listening on a network (vite --host), every device on that network could sign in as anybody.- It answers a request whose socket ends on this machine:
127.0.0.0/8,::1, or either written as an IPv4-mapped IPv6 address. Any other is answered403with a sentence that says why, and the app'ssignInis not called. What a header says of where a request came from (X-Forwarded-For) is not read. - New on
mossDev:signInFromNetwork: trueanswers it to every device that can reach the dev server, for trying a signed-in screen on a phone on a network you trust. - A developer who opened
/dev/as/<who>from a second device setssignInFromNetwork: truewhere the app addsmossDevitself, ordev.signInFromNetwork: trueinnisc.config.tsundernisc dev. - New in
nisc.config.ts, fornisc dev:dev.signInFromNetwork,dev.watch,dev.owned. The command made the dev plugin with none of its options, so an app undernisc devcould not say which changed files re-boot it, or that a route of its own outside moss's prefixes goes to the app server and not to vite. All three are handed to the plugin as they are, and read when the command starts. - A dev server behind a proxy on the same machine sees every request as the proxy's, which is this machine: the check cannot tell those apart, and a proxy that listens on a network is its owner's to close.
(8) THE TERMINAL
Every render target is told the connection's status, the same way.
- After a connection dropped, the last screen stayed up and nothing on it said so. The wire has always known (
wire.status()), and told whoever subscribed; but what a target renders against had no status on it, so the React, Vue and DOM targets were never told, and the tty and ink targets were told only where the host remembered to passstatus: wire.status. TerminalApi, what a target renders against, gainsstatus(): WireStatusbesideframe()andcanvasTree(). The conductor (createTerminal,mountTerminal) fills it from its wire, and a change of status is anupdatelike a change of a tree, as it already was.ttyTargetandinkTargettake nostatus. They read it from what they render against. An app deletes the line:ttyTarget({ input, output, status: wire.status })→ttyTarget({ input, output }), and the same forinkTarget. What they print is what they printed. (relay'stty.ts,ink.tsand ink check, and lyceum's ssh door, are changed here.)- The React, Vue and DOM targets say the status on their root element, as
data-wire-status="connecting | open | closed | incompatible | static", from the moment they mount until they are destroyed, and draw nothing for it. What a lost connection looks like is the app's: its stylesheet ([data-wire-status='closed'] …), or a component of its kit that reads the status where the target provides what it renders against,useContext(TerminalApiContext)?.status()in React andinject(TerminalApiKey)?.status()in Vue, and is drawn again when it changes. - A page drawn on the server (
renderSnapshotof each target) is drawn with the status its terminal starts in, so a kit component that draws the status draws the same thing on both sides and the page is adopted:staticfor a snapshot that sayslive: false(no socket is opened for it),connectingfor every other. - A target of an app's own is handed the status and may read it; one that hands what it renders against on to a moss target has nothing to change (lyceum's does). Code that builds a
TerminalApiby hand to drive a target directly (a test) addsstatus: () => 'open'.
The wire holds one socket at a time.
wire.reset()on a socket that is not open connects again. Pressed while a connect was still in flight (the reset key held down on a slow network, a reset straight after the page loads), the socket it was waiting on was left as it was, with the wire still listening to it. Both opened: two sockets wrote one snapshot, a sign-out or a drop heard on either was taken for the connection's, and the first to close scheduled a third. The wire lets go of the socket it had before it makes another: it is no longer listened to, and it is closed.- A wire whose status is
incompatiblestops retrying, andreset()is the way to try again once the server has been updated. A connection made that way kept the mark of the one before it: when it later dropped for any reason, the status readincompatibleagain and nothing retried. Whether the two ends can speak is found out for each connection now. - An app has nothing to change.
A connection the server refuses is tried again more slowly each time.
- The wire waits before it connects again: half a second to a second at first, twice as long after each failure, up to thirty seconds. It forgot that count whenever a socket opened. A server opens a connection before it refuses one, because a refusal is a message and a close code:
4503when there is no place left for a shell for nobody,4500when it could not open the session. So a terminal that was refused each time connected again every half second to a second, for as long as it was refused, and each of those connects had the server do the refusing again. - The count is forgotten when a connection is served its first frame, which is the first thing a shell sends whatever attaches to it. A connection that drops after that is tried again after the first, short wait, as before.
- An app has nothing to change. A terminal that connects where the server serves it no shell (a path that is no page, in an app that declares no shell) is never served a frame: after each drop it waits longer, up to the thirty seconds.
In the full-screen terminal, digits that arrive together are the number they spell, and
onQuitis the terminal ending.inkTargetacts on a typed number:12clicks what is marked[12]. It read one digit for each key it was handed, and two keys pressed in turn can be handed over as one (a slow link, an ssh session, a paste): those were dropped, and nothing happened. They are read as the number they spell.onQuitwas called whenever the ink app unmounted, so a host that destroyed the terminal itself (terminal.destroy(), a swap to another target) was told that somebody quit. A handler that ends the process ended it on a swap.onQuitis not called for a destroy now, asttyTarget's never was. A host that relied ononQuitto clean up after its owndestroy()does that cleanup where it callsdestroy(). relay and lyceum did not rely on it.- A component of the kit that throws while ink draws it ends the ink app, and ink says so by rejecting. Nothing handled that: the rejection reached the process, and Node ends a process on a rejection nobody handles, with every other terminal in it (a host that serves terminals over ssh has one for each person). It is logged (
[moss/ink] the terminal ended on an error:) and reported throughonQuitnow, like a quit: that terminal ends, and the process and the others go on.
WHAT AN APP CHANGES
In this order. An app that uses no integrations and no tide skips steps 3 and 4, and one whose terminal is built on
@niscorp/moss/clientand moss's targets skips step 10.- Move the packages together.
@niscorp/moss,@niscorp/nisc,@niscorp/cliandcreate-niscare one breaking release;@niscorp/tidemoves with them for an app that runs tide, because the store and the driver are in it now. - Let the compiler list the imports. Every name that left
@niscorp/mossis in the table at the top, with what to write instead. A hand-written client or a check that opens a socket takesPROTOCOL,CLOSE_INVALID_TOKEN,CLOSE_SIGNED_OUT,offerToken,ServerMessageandConnectionfrom@niscorp/moss/protocol. - Integrations. Create the feature (
createIntegrations({...})), moveintegrationActoroff the manifest into it, name it infeatures, and callintegrationByKeyandcallIntegrationon it. Write the app's own vocabulary asextension(a schema, and the checks and helpers lifted from "What a host with a store takes over"), have each vendor's bundle say it underextension, and read placements, attachments and listings offlistIntegrations()or offintegrations.extensionin an entry of the app's own. Where a screen framed a vendor's page, mount a route of the app's own for it. Import each integration again after the first start, so its row holds an extension. Check thatruntime.dbhastransaction. SetoutboundTimeoutMsif an integration answers in more than thirty seconds. Moveruntime.signingSeedtocreateIntegrations({ signingSeed }), and give it 32 bytes as base64, or none: a shorter one refuses the boot. - Tide. Import the store from
@niscorp/tide/postgresand the driver from@niscorp/tide. Replacefacts: { tide, identity, chain }withcreateTideFacts({ tide, identity, chain })infeatures. - The manifest. Remove any field
NiscAppdoes not have. Replaceserver.shells.adopt()withserver.refresh(), andserver.shells.stop()withserver.close(). Where code edited the manifest aftercreateServer(most oftenapp.actions, thenrefresh()), hand the actions over withserver.replaceActions(name, actions), and read the actions in force fromserver.actions(). Where the app runs several processes, hand the set to each of them: a set handed toserver.replaceActionsis that process's. Hand no set over under a feature's name: it is that feature's. - Functions. Replace
session.runtimewith the runtime the app created,session.policywithserver.policyForRoles(session.roles), and an engine built from the two withsession.answerIntent(writers, asked). Where a function callsregisterAction,removeAction,registerFragment,addCanvas,removeCanvas,setCanvasLayout,setLayout,setPhrasesordisposeonsession.shell, take the whole shell:unverifiedShell(session), from@niscorp/moss/unverified. - Who is calling. Replace
session: devSessionwithsession: 'dev-open'. CallmintSessionandrevokeAllForonly wheresessionis'sessions'; a verifier of the app's own that read moss's stored sessions becomessession: 'sessions', or keeps a credential of its own. An app that names no integrations feature, shipsext.…actions of its own and relied oninstalledto narrow them, names the feature. Haveidentity.resolveanswer{ roles: ['public'], scope: {} }for a principal it does not know, where it threw. Where code matched401no_identity, match500identity_failed. Giveruntime.identityMax1 or more, or leave it unset. Where a deployment has aruntime.fabric, have its transport handapplythe object that was published, parsed, with nothing added to it. - Checks. Replace
resolveCatalogandresolveCatalogForRoleswithserver.catalogOf(principal),resolvePolicyForRolesandresolvePolicywithserver.policyForRoles(roles), andresolveRoleswith(await server.identity(principal)).roles; replace a boot check put together by hand withserver.charterReport()orverifyApp(app, grants).server.verifyActionsandserver.generation()are gone:server.replaceActionsthrows with the reason when a set is refused and changes nothing, and the counter is the server's. - The terminal. Delete
status: wire.statusfromttyTargetandinkTarget. Addstatus: () => 'open'where a test builds aTerminalApiby hand. Style[data-wire-status='closed']if a lost connection should show. - A terminal written by hand. Send the six messages with exactly their fields, and nova's events with exactly theirs, each tagged with a canvas the shell has; treat a
4503close as one to retry later, and later each time. - Telemetry. A sink that counts
vex.executespans leaves out, or counts apart, the ones whosefingerprintstarts withintegrations/: the registry's reads and writes are spans now. - Before the first start. A deployment that boots with
migrations: 'verify'runsnisc migrate:nisc.mosshas a third and a fourth migration, and the fourth drops columns. A page that more than a thousand visitors have open at once, none signed in, setsruntime.anonymousShellMax. A host that callsserver.close()on whatserveanswered expects the port to be let go. A host that passedruntime.socketCompressiontoattachSocketitself deletes the argument: the server's socket carries it. - In development.
/dev/as/<who>answers this machine only: setsignInFromNetwork: trueonmossDevto open it to other devices. A terminal that is not a page is closed (1001) when an edit re-boots the app, and connects again.
0.4.0
Minor Changes
f834f3f:
evaluatechecks a config object once and only evaluates it after that;prismTransformis gone. Breaking.BREAKING — approved by moccadroid, 2026-10-09: replace
prismTransformwithevaluate(transform: evaluate), and pass limits as{ limits: … }.Prism had three ways to run a config, and the one a host was told to hand to its seam (
prismTransform) was the one that reported worst, while the hosts on a hot path each wrappedevaluate, which checked the config on every call. There are two now, with one job each.evaluate(config, source, options?)— for a config a program holds.- The first time it is handed a config object it checks it and keeps its tree; the second time it prepares the tree as
compilewould; from then on a call only evaluates. Four fields picked and renamed, the same config object again: 3.5 µs before, 0.4 µs now. A nested choice on two fields: 18 µs before, 0.7 µs now. A config that is a new object on every call costs what it did. - It is kept by the object: a config changed in place after its first call is not read again. Hand over a new object.
- Both arguments are
unknown, so it is what a host's transform seam takes:createTide({ transform: evaluate }),createUpgrader(grammars, { transform: evaluate }). - The third argument is
{ limits?, check? }. Limits were the third argument themselves:evaluate(config, source, { maxSteps: 100 })is nowevaluate(config, source, { limits: { maxSteps: 100 } }). check: 'always'is for writing a config, for tests and for tools: nothing is kept, the config is checked on every call, and the source must be plain JSON (a source holdingundefined, a function, aDateor a non-finite number is refused withE_TYPE).- An invalid config is a
PrismError(E_SCHEMA) naming the part that is wrong, however deep the config.prismTransformthrew zod's own error, and aRangeErrorfor a config nested far past the limit.
evaluateSafetakes the same arguments.compileandexecute— for a config that is stored. Unchanged.prismTransformis removed, from@niscorp/prismand from@niscorp/prism/migrations. What it added overevaluatewas a check that the source is plain JSON; that check is nowcheck: 'always', and a seam's source is taken as it is.moss, nova, vex, loom, nisc, create-nisc and the cli move with it. Each has Prism as a peer or a dependency, or has one of those that do, and a Prism at 0.3 is outside the range their last versions accept: an app takes them together. Nothing in vex, loom or the cli changed beyond that range.
moss's shell and nova's
$prismbinding calledevaluateon every use and so paid the check each time: an endpoint's request and response under moss, and a$prismvalue on each draw and for each row of a list, are 8 to 45 times faster for the configs measured. tide's and strata's documentation nameevaluatewhere they namedprismTransform.What to change
import { prismTransform } from '@niscorp/prism'or'@niscorp/prism/migrations'→import { evaluate } from '@niscorp/prism', andtransform: prismTransform→transform: evaluate. Every app made bycreate-nischas this insrc/dev/strata.ts; the app's typecheck andnpm run strataboth stop on that line until it is changed.- A third argument of limits becomes
{ limits: … }. - A wrapper that parsed the config before calling
evaluate(evaluate(ConfigSchema.parse(config), …)) makes a new object on every call and so is checked on every call: handevaluatethe config itself. - Code that changes a config object in place and evaluates it again must hand over a new object, or pass
{ check: 'always' }. - A host that relied on
prismTransformrefusing a source that is not plain JSON checks it itself, or passes{ check: 'always' }.
- The first time it is handed a config object it checks it and keeps its tree; the second time it prepares the tree as
Patch Changes
- Updated dependencies [023bcf9]
- Updated dependencies [1db6df4]
- Updated dependencies [6d098e4]
- Updated dependencies [8b4d97e]
- Updated dependencies [f834f3f]
- Updated dependencies [b084e30]
- Updated dependencies [663d724]
- Updated dependencies [6512d91]
- Updated dependencies [7d682b9]
- Updated dependencies [d2e9d22]
- Updated dependencies [e0e8abc]
- Updated dependencies [7fe1135]
- @niscorp/[email protected]
- @niscorp/[email protected]
- @niscorp/[email protected]
0.3.3
Patch Changes
78a563f: A page served by its app no longer holds its session: the browser does, in a cookie no script can read.
A session token in a page — in
localStorage, or in the script-readable cookie copybrowserEnv({ cookie: true })kept — is there for anything else that runs in that page to take. Now the server writes the session into a cookie the page cannot read (HttpOnly,SameSite=Lax;__Host-prefixed andSecureover https) in its answer to the socket's upgrade, and the browser sends it by itself with the two requests that need to know who is asking: the page, and the upgrade. After a reload the page holds nothing.The setup this stops working for: code that read the session token out of the page.
localStorage['nisc.token']is empty once the browser holds the session, and the cookie copy is no longer written, so anything that took the token from there — to call a route withAuthorization: Bearer, say — finds nothing. No route answers to the new cookie, on purpose: a browser sends a cookie with requests another site makes it send. AndrenderDocumentno longer answers a cookie that does not resolve with aSet-Cookiethat takes it back; the upgrade is the one place the cookie is written.What happens, and to whom:
- Only the app's own page. The cookie is read and written when the upgrade's
Originis the host it was addressed to, or one listed in the newruntime.origins(for a proxy that rewritesHost). A page on another origin — which a browser sends the cookie with all the same — is nobody. A terminal that is not a page of its app (a process onnodeEnv, a page served from somewhere else, a browser that refuses cookies) holds its own token and offers it, exactly as before. - Nobody is signed out by the update. A token an earlier build left in storage is offered once, moved into the cookie by the answer, and removed from storage along with the old copy.
- Seats. The cookie is named by the terminal's
tokenKey, so several people can be signed in on one origin as before; the terminal names its key in the socket's address (?key=), andrenderDocument'stokenKeyreads the same one. The name ends with the port when the page is on one — a browser keeps cookies by host, not port, and two apps on localhost would otherwise sign each other out — sorenderDocumenttakesrequest.host;mountSiteand the vite plugin pass it. - Signing out. A cookie that no longer resolves is taken back on the next upgrade. Under an app's own identity provider the token may go on resolving, so a terminal that was signed out says so (
?leave=1) and the cookie is taken back whatever it holds. - A sign-in the app answers over HTTP (a link redeemed, a provider's callback) sets the cookie itself:
sessionCookies(request, token, { key?, lastsMs? }), exported from the package root, returns theSet-Cookievalues. The page that follows is signed in and was never handed the token. The vite plugin's/dev/as/<who>does this now. - A transport of your own passes what the request carried —
connection.upgrade = { offered, origin, host, cookie }— and, to let the cookie be written, leaves the request unanswered until the connection is first used, answering then with the cookies moss named throughupgrade.answer(cookies)(moss names them before it uses the connection, and names none when there are none to write). One that has already answered reads the cookie and writes none.attachSocketdoes both. - A
WireEnvof your own is unchanged: withouttokens.helda terminal stores its token as it always did.
What to change: nothing in an app that uses
createWirewithbrowserEnv()and serves withserve,nisc start, the vite plugin orattachSocket.browserEnv({ cookie: true })still compiles and the option now does nothing; drop it when convenient. A sign-in page that writes the token tolocalStoragekeeps working (the token is moved on the next upgrade), and is better replaced bysessionCookieson the response. Behind a proxy that rewritesHost, list the app's origin inruntime.origins— until then every page looks like one served from somewhere else, so the app works as it did before this change, each terminal holding its own token, and moss says so once in the log. An app that draws its own pages passesrequest.hosttorenderDocument; behind a proxy that rewritesHostto an upstream on another port,siteis where the port is taken from.- Only the app's own page. The cookie is read and written when the upgrade's
51dc5ce: DESIGN says what a rebuilt shell holds. "Durability" named a projection as the durable thing, and moss has built none ("Deliberately unbuilt" lists it). It now says the database is the durable thing: a shell is rebuilt on the next connection from definitions and whatever the app reads from rows as it builds, and evicting an idle shell, restarting the process and
reseteach cost what was only in the shell — the screens someone had opened, and what they had typed. Two source comments now say the same. No code changed.What to change: nothing.
da42472: With
shellFrameDeltaon, a canvas whose frame is over 64 KB is sent whole, and no longer compared.Encoding a delta costs the server in proportion to the size of the two frames, not of the change, and it does the work on the thread every session shares. Nothing bounded it. Measured on the encoder alone: a 0.3 MB frame held the thread 65 ms for one changed letter, a 2.7 MB frame over a second, and at 20 MB it ran for eight seconds, took a gigabyte and failed — after which the frame was sent whole anyway. Every session on the process waited each time. A large frame needs nothing unusual: a long table, or one long value in a bound field, which any terminal can send. Now a change is sent whole, with no encode, when the frame the terminals hold or the frame replacing it is longer than 64 KB — where an in-place change measured about 7 ms.
Nothing moves with
shellFrameDeltaoff (the default), for a terminal that did not ask for deltas, or on a canvas whose frames are at most 64 KB: those are byte for byte what they were. The rendered tree is the same in every case.What to change: nothing. With deltas on, a change to a canvas over 64 KB now costs that canvas's whole frame on the wire — what a terminal that never asked for deltas is sent — where it had cost a delta, after the encode.
a91b5db:
migrateTables: the step that changes the tables, and checks the app's entries against what it leaves before it commits.runtime.tableshands it the app's own.A deployment whose runtime says
migrations: 'verify'refuses to start with anything pending, and moss had nothing to run first. An app's own tables were outside that run, so'verify'did not cover them.runtime.tablestakes the app's own table sequences (and another owner's the app keeps — tide'sTIDE_SEQUENCE). They join moss's one ledgered run, after moss's own. A runtime that hands tables over does not also migrate them itself.migrateTables(runtime, app, { dryRun? })applies everything pending in that run and, before it may commit, checks every entry of the manifest against the schema as it would stand: a read is resolved, a write is put to the gates it passes before it runs (vex'smutationMisfits). An entry that does not fit refuses the run — strata'sDOES_NOT_FIT, each reason asfingerprint: why— and nothing is applied. It checks when nothing was pending too, so a release that changes an entry and no table is checked.One kind of entry is listed and not refused: one that already did not fit before the run and that the database already holds seeded exactly as it is. A new or changed entry gets no such pass. The report also says which tables and columns a run removed and which column types it changed.
It sees entries and nothing else: not SQL written by hand, not a column a scope rule stamps, not what a value means.
What to change: nothing. A boot applies and verifies exactly what it did: a runtime without
tablesruns the same sequences in the same order.812c464:
runtime.vexConfighands the query engine its settings.Moss built the vex engine with no
config, so a deployment ran on vex's defaults — 100 rows for a list that states no limit, 1000 at most, a ten-second read timeout — and had no way to change one.NiscRuntimetakes an optionalvexConfig, passed tocreateQueryEngineas itsconfigunread:defaultLimit,maxLimit,capAuthored,statementTimeoutMsand the rest ofQueryEngineConfig['config']. Unset, the engine is built exactly as before.What to change: nothing. Set
vexConfigin your runtime to change one of the engine's numbers — for examplevexConfig: { capAuthored: true }to keep seeded entries undermaxLimit.770f754: A frame the websocket transport refuses closes that connection; it no longer ends the process.
attachSocketlistened for a connection's messages and its close, and not for its errors.wsreports what it refuses from a client — a malformed frame, text that is not UTF-8, a message over its size limit — as anerroron that connection, and Node throws anerrornobody listens for. So one such frame, from any client that could open the socket, signed in or not, ended the server process and every session on it. The transport now listens:wshas already closed the connection with the status that says why (1002, 1007, 1009), one line is logged ([moss/node] a connection was closed on what it sent: …), and nothing else is touched. Every host that callsattachSocketgets it —serve,nisc start, the vite plugin, an app's own listener.What to change: nothing. An app this had happened to was not running. A process-level
uncaughtExceptionhandler that was catching these no longer hears them.61a4060: A sign-in made over the socket no longer hands the page its token. It reaches the page sealed, and only that browser can open it.
session.grant(token)happens on a socket, and a socket cannot write a cookie — so the token has to travel through the page to the terminal's next upgrade, where the session cookie is written. It used to travel as itself, which for that moment put it where any script in the page could take it. Now it travels encrypted with a key the browser holds in a second cookie no script can read (the seal), given by the answer to the browser's first upgrade. The page hands the sealed sign-in back and cannot open it; neither can anybody it is shown to, on this machine or another — without that browser's seal it opens nothing, and after a minute it opens nothing at all.The setup this stops working for: none that worked. What is new for a browser: a terminal on
browserEnv()now namessealed=1in the socket's address, and a browser that opens a socket to its app is given one more cookie —nisc.seal(__Http-nisc.sealover https):HttpOnly,SameSite=Lax, sent with the socket's path and no other, kept until the browser closes. It is given whether or not anybody signs in. It is a key, not a name: it is not stored or looked up on the server, and no page request carries it, so a page drawn for nobody stays cacheable for everybody.- Any credential, any number of processes. Nothing is kept on the server between the sign-in and the upgrade that completes it, so it does not matter which process answers either, and the token can be moss's own or an app's provider's.
- Terminals that keep their own token are handed it as before: a process on
nodeEnv, a page served from another origin, aWireEnvwithouttokens.held, a transport that cannot answer an upgrade late.{ type: 'session', token }is unchanged for them. - A browser that does not keep the seal — it sends none back with the sealed sign-in — is told (
seal_not_kept); its terminal stops asking, and the next sign-in reaches it as a token. That costs such a browser one failed sign-in. - A terminal written by hand that wants sealed sign-ins:
?sealed=1on the address from the app's own origin; a{ type: 'session', sealed }message is offered back withofferToken(null, sealed).
The seal's name over https is what keeps a script from supplying it: the seal is given after the page's script has started, and a script already running then could otherwise set one of its own first and open the sign-in that follows.
__Http-is a name a browser lets only an HTTP answer set. Not closed: a browser that does not know that prefix treats it as any other name; and a server on a sibling subdomain can still set one, which with a script in the page as well is the same opening.What to change: nothing.
8012cd8: A message a terminal sends over the socket is at most 256 KB; a host that takes larger ones says so with
attachSocket's newmaxMessageBytes.The setup this stops working for: an app whose terminal sends a message over 256 KB — in practice a file put into a model value as base64 (a picked picture is megabytes). That message now closes the connection with
1009and what it carried does not arrive; the terminal reconnects as after any other close, and a signed-in person's shell is as it was. To keep the old behaviour, a host that callsattachSocketpasses the size it takes:attachSocket(httpServer, server.socket, '/socket', { maxMessageBytes: 100 * 1024 * 1024 })is whatwsdid before.serve(),nisc startand the vite plugin take the default and cannot raise it; an app on one of those that needs more attaches the socket itself. Better than raising it, and whatAGENTS.mdrule 9a says: a file never goes over the socket — the picker sends it to a route the app mounts, and the event carries what the route answered.Why there is a limit.
attachSocketcreated itsWebSocketServerwith nomaxPayload, so the limit wasws's own 100 MiB. Whatever a message carries, the server keeps — the value in the shell, and again in the last frame it sent — and sends back down in every later frame of that canvas. So the limit is what one connection, signed in or not, can make the process hold and re-send. Measured per open connection: 0.2 MB with nothing sent, 1.5 MB after a 256 KB value, 3.3 MB after 1 MiB, 48 MB after 16 MiB. Compression does not bound it — a 64 MB message of one repeated letter is 60 KB on the wire — so the count is of the message once inflated. What a terminal legitimately sends is an event: a press, a typed value, a row handed back. A few KB.What to change: nothing, unless a terminal of yours sends more than 256 KB in one message — then one of the two things above. A typed value still has about 250,000 characters of room.
10875eb: A terminal's session token no longer rides the socket's address. It is offered in a header of the same request, and a token in the address is not read.
The setup this stops working for: a terminal and a server that are not the same version. The wire protocol is now 2 and the server speaks nothing older, so a terminal built before this is refused with
client_too_oldand a4426close — the existing "reload to get the current build" path — and a terminal built after it is refused by an older server withserver_too_old. An app's terminal and server are one package and deploy together, so in practice this is a tab left open across the deploy: it reloads once. Three things written by hand also change:- A terminal written by hand (
new WebSocket(…/socket?token=…), as a check or a script does) names the protocol and offers the token:new WebSocket(${base}/socket?protocol=${PROTOCOL}, offerToken(token)).PROTOCOLandofferTokenare exported from@niscorp/moss;offerToken(null)is a terminal that is nobody. - A
WireEnvof your own:sockettakes{ url, offered }where it tookurl, and constructsnew WebSocket(url, offered). One argument on purpose — an env that kept the old shape would have gone on compiling and connected everybody as nobody; this way it does not compile. - A transport of your own (anything that is not
attachSocket) hands over what the request offered:connection.upgrade = { offered }, theSec-WebSocket-Protocolheader split on commas, and answers the subprotocolnisc. Without it every terminal is served as nobody.
Why. The token rode the upgrade's query string, and an address is what every proxy, load balancer and access log between a browser and the app writes down. The request has one header every host lets a terminal set, a browser included: the subprotocols it offers. So the terminal offers
nisc, and beside it its token, base64url (a subprotocol is an HTTP token; a session token need not be); the server answersniscand never the other. Who a terminal is is still decided on the upgrade request, before anything is served, so there is no connection that is open and waiting to be told. A server that kept reading?token=for older terminals would have kept the address open for as long as anything used it, which is why protocol 1 is refused rather than served.What to change: nothing in an app that uses
createWirewithbrowserEnv()ornodeEnv()and serves withserve,nisc start, the vite plugin orattachSocket. Otherwise one of the three above. Anything that read?token=off the socket's address — a proxy rule, a check — no longer finds it there.- A terminal written by hand (
fc50d20:
AGENTS.mdsays where files go. Rule 9a: file pickers live in the kit; a file never goes over the socket; the app saves it wherever it wants. The review pass's "nofetchoutside the endpoint layer" gains its one exception, to send a file. No code changed.A picker that puts a file in its model value sends it over the socket as base64, and the tree brings it back on every render of that canvas. Measured against a moss server: one 15 MB file was 20 MB up and 100 MB down across five renders. Sent by the picker to a route the app mounts on its server, or to a system the app already has, the same file put nothing on the socket.
moss gains a test and nothing else: a route an app adds to the built server reads who is asking, as moss's own surfaces do.
What to change: nothing for the rule itself. But this release also limits a message a terminal sends to 256 KB (the moss entry on the socket's message limit), so a picker that emits a file's bytes as its model value now works only for a file under about 190 KB: send the file from the picker, as rule 9a says, or raise the limit where the app attaches the socket.
0.3.2
Patch Changes
a7d4de4:
server.charterReport()— the charter report the server was verified by.createServerrunsverifyCharterat boot and again on everyrefresh, refuses on the report'serrors, and read nothing else of it. The rest was computed and dropped: thewarnings(an action no role grants, anallowthat matches nothing) and each role's closureissues. A host that wanted them had to runverifyCharteritself, over a data universe it built by hand.charterReport()returns that report: boot's, then that of eachrefreshthat passed. A refresh that is refused leaves the one the server is still serving on. Nothing is printed, and boot refuses exactly what it refused before.const server = await createServer(app, runtime); for (const warning of server.charterReport().warnings) console.warn(`${warning.rule}: ${warning.detail}`);DOCS.md says one thing about what it holds: a push whose target is a binding (
@event.payload,$.target) is listed among a role'sissuesas "not in the catalog", though it is resolved when the step runs.What to change: nothing.
f70d461: DOCS and DESIGN say what boot refuses.
createServerrefuses onverifyCharter's errors. The closure audit's findings are filed under each role'sissuesin the report and are neither refused nor printed: a role granted an action that pushes one it is not granted boots, and the press does nothing. Two source comments now say the same. No code changed.What to change: nothing.
a7d4de4: The docs say a charter defines a role named
public. A request with no session, and a signed-in principal with no assignment, resolve to that role, and the name is fixed. A charter without it still boots and nothing is said; those requests then fail withUnknown role "public"(a 500, a socket closed4500, a page served undrawn).public: []grants nothing. README, DOCS and DESIGN now say so, and a test holds it. No code changed.What to change: nothing, for a charter that has the role. One that names its visitor role something else adds
publicbeside it.979b3e3:
src/principal.tsis text again. One separator inwearableOfwas written as a raw NUL byte where the same file writes the escape'\0'two functions down, so git treated the whole file as binary: its diffs showedBin, and a text search (git grep -I) skipped the file that defineswearableOf,resolveForandverifyCharter.The byte is now the escape. The string it produces is the same one, and the built package is the same code: every built file is identical apart from the names of two chunks, which are hashes that take the embedded source text in.
What to change: nothing.
0.3.1
Patch Changes
fe30458: Every package exports its
package.json, so an app can say which version of a package it runs by reading it from the package:import prism from '@niscorp/prism/package.json' with { type: 'json' }; prism.version; // the version that is installed, not the range that asked for itUntil now the
exportsmap hid it, and the only way to the version was a path intonode_modules.check:packagesinstalls the tarballs and reads every package's version this way.What to change: nothing. An app that read a version by path can read it by name.
1e6d55d: A pool's
queryandtransactionare called on the pool, and a transaction'squeryon the transaction — never taken off the object first. A driver's own object has them as methods that need their receiver, and taken off they failed inside the driver, on a property nothing in nisc names:- strata —
migrateandupgradeStoreover aPGlitethrewCannot read properties of undefined (reading '_checkReady');statusandreadLedgerdid too, once a ledger existed. A pool whosetransactionhands a checked-outpgclient through as the transaction failed inmigratethe same way. - moss —
createTideStoretookqueryoff the transaction. Over a pool that hands its client through, every transaction of the store failed inside the driver, and tide recorded the run as deferred: nothing threw, and the effect never ran. - vex —
createPostgresAdapterwith a read limit set (limitReads) threw on a pool whosetransactionis a method.
A
PGlitenow works as a pool as it is, and so does apgwrapper that passes its client through. A pool built from closures (createPglitePool, a wrapper that builds its ownquery) behaves as before.What to change: nothing.
- strata —
0.3.0
Minor Changes
f1cec45: A Prism config that validates no longer names an op Prism does not have.
validateaccepted{ card: { $fetch: … } }and{ $eval: "…" }: the plain-object branch refused only the names of ops that exist, so any other$key passed as a template key. Such a config could be stored — an endpoint request, a vex mapping, a strata migration — and then failed every time it was evaluated (E_NODE_SHAPE, "Unsupported node shape").A key that starts with
$is now an op's name and nothing else. The template branch refuses every$key — the rule the evaluator already held — sovalidatereports it with the key and where it is (card.$fetch: Not a Prism op. A key that starts with "$" names an op; data with such a key goes in $const.), andevaluateandcompilefail withE_SCHEMAbefore anything runs. An op's name used as a template key keeps its own message.E_NODE_SHAPEis left for a tree that never went through the schema: an IR handed toexecute. Adding an op no longer takes a key away from templates.- prism — the change, and a grammar narrowing:
nisc.prism2, a marker with no document step (a key that never evaluated has nothing to be rewritten to).$keys that are data are untouched: a$constpayload,$withnames,$renameKeysand$fromEntrieskeys. The JSON Schema's template key pattern is^(?!\$)instead of a list of every op. - nova, vex, moss, loom, cli, nisc — no change of their own. They depend on prism and are released with it, so the set installs together; the configs they hand to prism (endpoint
requestandresponse,$prismbindings, vex mappings) are held to the same rule. - create-nisc — the templates' sources are recorded at
nisc.prism2.
What to change: in a config with a
$-prefixed key that is not a Prism op, remove the key — or put the object in$constif it is literal data. An app that keeps astrata.lock.jsonrunspnpm strata upgrade, thenpnpm strata verify; there is nothing to edit unless it has such a config. Everything else: nothing.BREAKING — approved by moccadroid, 2026-10-04: a config with a
$-prefixed key that is not a Prism op no longer validates, compiles or evaluates — also where evaluation never reached the key (an untaken$casebranch, a short-circuited$or, a$mapbody over an empty array), which used to work. A vex seed mapping with one fails when it is seeded, at boot, instead of on each replay. Code that matchedE_NODE_SHAPEfromevaluategetsE_SCHEMA. The@niscorppackages that depend on prism move with it, so an app moves them together (@niscorp/nisc0.3.0).- prism — the change, and a grammar narrowing:
Patch Changes
de6d980: A fix for the last fix:
nova:headis the document's<head>, and holds what a head holds. 0.1.4 gave it five props (title,description,image,kind,structured) and wrote tags from them, which closed a head to whatever nova had been taught: noog:image:alt, noog:typebut two, no whole address inside structured data. That was wrong, and it is replaced, not extended. The maintainer withdrew 0.1.4's props on 2026-10-04; this ships as a patch on that decision.What to change (only an app that used 0.1.4's props): a
nova:headnode no longer takes props. Write its children instead —nova:title,nova:meta,nova:link,nova:script— each with the element's attributes as its props.{ title: X }becomes{ component: 'nova:title', children: X };descriptionbecomesnova:metawithname: 'description';imageandkindbecomenova:metawithproperty: 'og:image'andproperty: 'og:type';structuredbecomesnova:scriptwithtype: 'application/ld+json'anddata.og:titleandog:descriptionare no longer written from the title and description: say them if they are wanted. An address is written as given — a picture's is not made whole fromsite. Code that calledheadOf,placeHeadorcreateTitleKeeper, or readheadoffrenderDocument's result or a route report, takes the new shapes below.- nova — a head's children are the elements a head holds, named as HTML names them, and a child's props are that element's attributes, written as given: nova keeps no list of them, so any
name,propertyorrelcan be said. Nothing in a head is drawn on the screen and no name has to be registered. With more than one head on a screen they are read in the order the screen is drawn, and an element that says the same thing as an earlier one (the title, a<meta>of that name or property, the canonical address) takes its place; anything else stands beside its like. What runs or styles is refused, left out and named: a script a browser would execute (nova:scriptis a data block — a JSONtype, never asrc), a stylesheet,http-equiv, a handler attribute.headOf(api)returns{ elements, actions, refused }. In@niscorp/nova/document,placeHead(html, elements, { site, path })writes them — an element takes the place of the tag that said the same thing, where it stood; what it wrote is markeddata-nova-head, and a tag that gave way is kept inert in a<template data-nova-own>— andcreateHeadKeeper(document)replacescreateTitleKeeper: the whole head follows the screen in the page, and the document's own tags come back when the screen stops saying them. Removed: the five props, theHeadandHeadPropstypes,createTitleKeeper. - cli —
build,exportandstartwrite each path's head from those elements. A head that holds what it may not fails the path, like any other check.sitestill gives every path its own canonical address andog:url; it no longer touches a picture's address. - moss —
renderDocumentandexportDocumentsdo the same from the snapshot (headon the result is{ elements, actions, refused }; a refusal is logged and left out), and the terminal keeps the whole head off the wire. - create-nisc — the two own-shell templates write their welcome screen's head this way.
Where a script goes: in the app's own
index.html, which holds anything and from which every page is written, or in the kit component that needs it. A layout is data and does not carry one.- nova — a head's children are the elements a head holds, named as HTML names them, and a child's props are that element's attributes, written as given: nova keeps no list of them, so any
Updated dependencies [de6d980]
Updated dependencies [f1cec45]
- @niscorp/[email protected]
- @niscorp/[email protected]
- @niscorp/[email protected]
0.2.4
Patch Changes
af903c6: A page's head is its screen's own.
nisc exportwrote every path with the builtindex.html's<head>, so a site with more than one page shipped each of them under the front page's title, description, preview card and canonical address.What a screen says about itself is now a node in a layout: nova's
nova:head, withtitle,description,image,kindandstructuredbound to the action's data like any other node's props. It draws nothing — no adapter builds an element for it, no registry has to hold it, and a component is never handed one as a child — and it rides in the render tree, so it reaches a shell in the page, a snapshot a server drew and the trees on a wire alike.headOf(api)reads it off any of them; with more than one on the screen the last one speaks.- nova —
HEAD_NAME,HeadPropsSchema,HEAD_META,headOf,isHeadNode; and@niscorp/nova/document:placeHeadwrites a head into an HTML document (the tag that said the same thing is replaced, a missing one is added before</head>, everything else is left as it was; values are escaped) andcreateTitleKeeperkeeps a tab's title on it. The DOM, React and Vue adapters keep the title on the head as the screen moves, and give the page's own back when the screen has none. - cli —
build,exportandstartwrite each path's document with its screen's head. "Same twice" also fails a boot that says a different head the second time, and the route table says whose head each path goes out with once an app has one. New config fieldsite: the address the site is served at. With it every path says its own canonical address (<link rel="canonical">,og:url) and a head's picture gets a whole address; a build with several paths, a canonical tag inindex.htmland nositesays so. - moss —
renderDocumentandexportDocumentsdo the same from the snapshot (siteon the config,headon the result), and the terminal keeps the tab's title off the wire for every render target. - create-nisc — the two own-shell templates say their welcome screen's head.
What to change: nothing. An app with no
nova:headnode and nositeis built, served and drawn exactly as before. An app that rewrote its exported files to give each page a head can delete that step: put the node in the layout and setsite.- nova —
0.2.3
Patch Changes
- 48da7db:
mountSiteanswers a missing file with 404. A name with an extension that is neither a file indistnor a page of the manifest used to fall through to the catch-all and come back as the drawn page with a 200 — so a browser holding a page from before a deploy asked for its old script and was handed a document. A page whose own path has a dot in it (/docs/v1.2) is still that page. - 80e1ee8: Documentation: the packages are live, and releases are compatible.
STYLE_GUIDE.mdgains "The packages are live" — a release does not break an app that works on the one before it; what should go is deprecated and stays; a breaking change is a last resort that needs the maintainer's approval before it is written — andAGENTS.mdpoints every agent changing nisc itself to it. The status lines that said "pre-1.0, breaking changes expected" (nova), "API is pre-1.0 and moves" (moss) and "everything else may move" (tide) now say the same. moss's deprecatedinstalledIntegrationsis no longer described as scheduled for removal: it stays on the type. The style guide's Node floor is corrected to 22.12, what every package'senginesalready says. No code changes.
0.2.2
Patch Changes
- 633d0d1: Docs:
domTargetno longer redraws the page on every wire update — with a nova that keeps what did not change, an update costs what changed and nothing on the page loses its focus, scroll or animation to it. No change to moss's own code. - 45d6731:
installedIntegrationson the manifest is marked deprecated: the server never called it. Which integrations are live for a principal's tenant isinstalledon the recordidentity.resolvereturns.
0.2.1
Patch Changes
- 2548e43:
domTarget({ root, registry })with an app's own registry no longer injects nova's reference stylesheet or puts its class on the root — both restyled the app's kit over the app's own CSS. With no registry (the reference kit) nothing changes.
0.2.0
Minor Changes
- 3a9866e:
@niscorp/moss/vite—mossDev({ app }), the app server inside vite's dev process: loaded through vite so an edit re-boots it (the old server answers until the new one is up, then is let go with its timers), the socket attached once and delegating to whichever server is current,/and every page's path answered with its first screen drawn into vite's transformed index.html, moss's paths answered by the app server, and a dev-only/dev/as/<who>when the app can mint a token.viteis an optional peer.MOSS_PATHSis exported from@niscorp/moss/node.