# CVN > A kernel/host framework: a total, synchronous update over a Model, effects as Cmd data, hosts that perform them through declared ports — and the gates that keep it that way. ## Concepts ### Model One plain object. The kernel reads it through a **state cell** and writes it with `commit(patch)` or `replace(next)`; every write notifies subscribers. Nothing in the kernel keeps its own copy — a local `let state = getState()` in an async function is a snapshot that silently goes stale after the first `await`, and it was the single most repeated defect in the port CVN came from. ### update is total and synchronous An action runs to completion without waiting. It may commit to the model and it may **emit Cmds**; it may not fetch, sleep, read a clock, roll dice, or touch storage. Those are host capabilities, and the kernel's purity gate refuses a kernel module that names one. ### Cmd A Cmd is data: `{ kind: 'persist', key, value }`. The kernel says **what**; the host decides **how**. A Cmd the host cannot route is **reported** — `[cmd] not performed: no persist port` — never dropped, because a dropped Cmd is exactly the silent no-op this shape exists to make impossible. ### Dispatcher While an action is running, `perform(cmd)` **records**. When the outermost action finishes, the dispatcher **drains** every recorded Cmd through the host's `performNow`, in order. A nested dispatch keeps the outer list. Outside any dispatch, `perform` is immediate. See [Dispatcher](/dispatcher). ### Host and ports A host is an object implementing [`HostPorts`](/hosts): `store`, `now`, `defer`, `every`, `fetch`, `randomBytes`. An application adds its own ports (its adapters, its crypto) as **declared** names the kernel destructures; the port-contract gate keeps declared, supplied and travelled sets equal. ### Declaration ```ts rt.declaration // { owned: [...], delegated: [], hostCapabilities: ['performNow'] } ``` `delegated` is the measure. A method is owned when the kernel does the thing — not when the host's function is reachable through a kernel-shaped door. A fallback that quietly calls the host while the kernel claims ownership is how a list reads empty while the work remains. ## Dispatcher ```ts import { createDispatcher } from '@enc-protocol/cvn' const d = createDispatcher({ runAction, performNow, warn }) d.dispatch(path, value) // runs the action; drains its Cmds afterwards d.perform(cmd) // records inside a dispatch; performs now outside d.inDispatch ``` Five behaviours, each pinned by a test in the package: 1. **Outside a dispatch, `perform` is immediate** and returns the host's result. 2. **Inside a dispatch, Cmds are recorded and drained after the action, in order.** `perform` returns `{ ok: true, value: Promise }`; the promise settles when the Cmd is performed. 3. **A nested dispatch keeps the outer list.** Only the outermost drains. 4. **An unroutable Cmd settles to `{ error }`** — the same shape as an adapter's own failure, so callers branch the same way whether the adapter refused or the routing did. 5. **A refusal is said** through `warn`: `[cmd] not performed: `. The [log gate](/gates) refuses a run that contains one. ## Gates A claim about the code that is not a test is a box someone ticked. CVN ships its gates as functions so a repository's tests can assert them against its own files. ### Purity ```ts import { checkPurity } from '@enc-protocol/cvn/gates' const violations = checkPurity({ 'kernel/a.ts': srcA, 'kernel/b.ts': srcB }, { exempt: { 'kernel/host-glue.ts': 'the one file that names the platform, by design' }, }) assert.deepEqual(violations, []) ``` Forbidden globals: `window document localStorage sessionStorage navigator fetch setTimeout setInterval XMLHttpRequest WebSocket Buffer process require __dirname`. Forbidden **members** (not namespaces): `Date.now Date.parse new Date Math.random performance.now`. Strings, template literals, comments and regex literals are stripped before matching — `/failed to fetch/` once counted as two uses of `fetch`. An exemption without a reason throws. ### Port contracts ```ts import { declaredInInterface, declaredByDestructure, suppliedInLiteral, diffPorts, untravelled } from '@enc-protocol/cvn/gates' ``` Three failures, each silent at runtime, each named here: a declared port nobody supplies (`missing`), a supplied port nobody declares (`extra`), a declared port nobody travels (`untravelled`). Wire all three into the repository's fast tier. ### Log gate `node tooling/log-gate.mjs [...]` refuses a run whose captured console contains a TypeError-class message, an unrouted Cmd, a failed unwrap, or a ladder rejection — de-duplicated and counted. An exemption is a regex with the spec's name and the reason, never a wider pattern. ### Certificates `node tooling/certify.mjs check | run | hash ` — see [Testing cadence](/testing). ## Hosts — the contract ```ts import type { HostPorts } from '@enc-protocol/cvn' interface HostPorts { name: 'node' | 'web' | 'native' | string store: StorePort // get/set/remove/keys + getJSON/setJSON now(): number // wall clock, ms — the kernel is forbidden Date.now defer(fn, ms): () => void // run later; returns cancel every(fn, ms): () => void // run repeatedly; returns stop fetch: FetchLike randomBytes(n): Uint8Array // the kernel is forbidden Math.random } ``` Two hosts ship with the package: [`@enc-protocol/cvn/node`](/hosts/node) and [`@enc-protocol/cvn/web`](/hosts/web). A third, native, lives in `zooid-native` — [how to build it](/hosts/native). ### The conformance suite Every host runs **the same assertions**: ```ts import { hostConformance } from '@enc-protocol/cvn/conformance' import { test } from 'node:test' import assert from 'node:assert/strict' hostConformance(() => createMyHost(), { test, assert }) ``` It is handed `test` and `assert` so it binds to node:test, vitest or jest without importing any of them, and a factory so each test gets a fresh host. It checks the store's four operations and JSON round-trips (a corrupt value yields the fallback, never a throw), that `now` is a millisecond epoch that moves forward, that `defer`'s cancel and `every`'s stop actually stop, that `randomBytes` returns `n` bytes and not the same twice. A host that passes conformance has not been proven to work in an app — it has been proven not to lie about the contract. That is what a native adopter needs before wiring an app: a host whose `every` never stops is found here, not in a battery report. ## Porting an app onto CVN The port CVN came from moved a 7,400-line application runtime into this shape without a rewrite. The order below is the order the obstacles fell; each step was blocked by the previous one. ### 0. Publish the measure Add a `declaration` to the runtime: `owned`, `delegated`, `hostCapabilities`. Make `delegated` a list the **code** produces, and make the loop that drives the port read that list — not a plan file. Twice the port "finished" because every box was ticked while the work behind one was half done. ### 1. Move decisions, one verb family at a time For each action family, extract the **decision** — expression in, patch or Cmd out — into a pure kernel module. Leave effect execution where it is. Measure inline state rebinds inside the action body; they fall from dozens to one. ### 2. Stop reading and assigning the host's state A function that assigns to a variable it does not own cannot move to another module. Replace every `state = …` with `cell.commit` / `cell.replace`, every read with `cell.value`. Count the sites from the function body, not the file — the plan's "≈430 reads" was a whole-file count; the body had 78. ### 3. Move the body; inject what it imports The action body becomes a kernel module taking an `ActionPorts` object. Anything it imported from the host tree that node cannot load (a directory import, a DOM-only module) becomes an **injected** port. Declare the interface; the contract gate keeps it honest. ### 4. Let the kernel build the dispatch `createRuntime` builds the runner **inside** the dispatcher, with `_perform` bound to it. No fallback to a host `action`: a fallback is how a method is counted as owned while something else still does the work. ### 5. Shrink the port surface by disposition, not by count Each remaining port has a written reason: platform, host data, session state, blocked on another move. Trade behaviour ports for data ports. Refuse repackaging. ### The traps, so they are not re-derived * `let state = getState()` in an async function is a stale snapshot after the first `await`. * A kernel module that imports a host-tree module with a directory import is one node cannot load. * A port the kernel declares and never travels stays wrong forever. * A `rt: any` ports bag hides a missing port until a branch throws inside a `catch`. * The runner must use the **dispatcher's** `perform`, or the dispatcher records nothing. * Suite "contention" is an app loop until a profile of the page says otherwise. ## The rules Each of these was paid for. They are stated as they were learned. ### A borrowed method is a symptom; the state behind it is the cause Extracting a function's logic into the kernel never moved the borrowed count. What moved it was making the **state** the function needed a capability the host constructs and hands to the kernel. Port the state, and the method follows. ### A port on host data is smaller than a port on host behaviour `needsEncryption(eventType)` handed in by the host is behaviour; `reverseTableMap` handed in is data, and the kernel applies the rule. Prefer the data port every time. Trading four behaviour ports for four data ports leaves the count unchanged and moves the rules into the kernel, which is the point. ### Repackaging is forbidden Collapsing six ports into one object reads as −5 and removes nothing. The count is of dependencies, not of properties. ### Every declared port is travelled A port the kernel declares and never uses is surface a new host implements for nothing — and gets wrong without finding out. `peerEnclave` was a tested, green capability reading a key nothing wrote; it survived because no app path travelled it. The contract gate refuses an untravelled port. ### A missing port is `undefined` until the first branch that needs it Ports typed `any` are not type errors. `dmEpochSet` was destructured by the ingest path and supplied by nobody for weeks; every branch that populated an epoch ladder threw inside a `catch`, and the warning sat in the browser log through every green run. Gate declared-vs-supplied mechanically. ### A Cmd the host cannot route is reported, never dropped `performNow` returns `{ ok: false, error: 'no pollNow port' }`, and the dispatcher says so. The log gate then refuses the run. ### A fix that only makes a symptom rarer is refuted A longer timeout, a bounded retry on a refusal, a re-run until green — each hides the same defect a little better. If a spec has to be edited to pass, stop and report it. ### Never claim a pass you did not run — and read the artifact before running again A "starved" verdict is not evidence about the machine. Two pages at 97 % of their main threads were a read-receipt ping-pong the DOM never showed; a CPU profile of the page named it in one run after five diagnoses by re-running had not. ### Batch the gate, mechanically Iterate on the fast tier; pay the full node + browser pass once per coherent group. As prose this slipped six times in a day; as a **certificate** the pre-commit hook checks, it cannot. See [Testing cadence](/testing). ## Runtime ```ts import { createRuntime } from '@enc-protocol/cvn' const rt = createRuntime({ initial: Model, runAction: (cell, perform, path, value?) => void, performNow: (cmd) => CmdResult, onWarning?: (message) => void, }) ``` | Member | Meaning | | ---------------------- | --------------------------------------------------- | | `getState()` | the current model | | `setState(patch)` | `cell.commit(patch)` + notify | | `replaceState(next)` | `cell.replace(next)` + notify | | `subscribe(fn)` | notified on every commit; returns unsubscribe | | `action(path, value?)` | run one action inside a dispatch | | `perform(cmd)` | record (inside a dispatch) or perform now (outside) | | `inDispatch` | whether an action is running | | `declaration` | `{ owned, delegated, hostCapabilities }` | ### The runner is late-bound to the dispatcher — on purpose `runAction` receives the **dispatcher's** `perform`. A runner built on any other `perform` — say the host's own — records nothing in this dispatcher: every Cmd is performed at once, the drain is empty, and `performNow` is a declared capability nobody exercises. This happened in the port CVN came from, and it was found only when a Cmd routed through that `performNow` changed nothing. The runtime binds it for you; if you build a runner by hand, the port gate asserts the binding. ### `runAction` is synchronous Cmds emitted before the action returns are drained after it. If your action awaits, Cmds emitted after the first `await` are outside the dispatch and are performed immediately — which is correct, and is why an action that needs ordering emits its Cmds first. ## Testing cadence Three tiers, three costs, one rule. | Tier | What | Cost | | ------- | -------------------------------------------- | ---------------------------- | | fast | pure kernel files under node:test | \~1.5 s targeted, \~20 s all | | node | real stack, no DOM, hosts driving the kernel | \~2 min | | browser | Playwright over the built app | \~2.5 min | **Iterate on fast. Pay node + browser once per coherent group of work. Never re-run what is proven.** ### Certificates make the rule mechanical Each tier carries the content hash of every file it reads, written when it passes. `certify run` runs only the tiers whose inputs changed; a page-component edit leaves the node tier fresh because the node tier never reads pages. A pre-commit hook runs `certify check` and refuses a tree that is not certified for every tier. The rule stopped slipping the day it became a check. ``` node tooling/certify.mjs check # FRESH / STALE per tier, exit 1 if any stale node tooling/certify.mjs run # run the stale tiers, certify on pass ``` Configure the tiers' input sets and commands for your repository at the top of the script; the MVP's copy is the reference. ### Instruments, in the order to reach for them 1. **Read the artifact.** The trace, the page-level trace, the captured log. A failure re-run before its artifact is read costs the artifact. 2. **Profile the page**, not the box. A "starved" verdict from load average or frame gaps is not evidence about the machine: the CPU profile of a page at 97 % busy named a read-receipt loop the DOM never showed. The MVP's capture fixture takes `CLEAN_PROFILE=1`. 3. **Count what went on the wire.** A regression test for a loop counts frames, not pixels. ### What a spec may not do If a spec has to be edited to pass, stop and report it. A longer timeout is not a fix. A retry on a refusal is not a fix. A run that is green two times in three has a defect, not a flake. ## Native — extending CVN in `zooid-native` This is the guide for the native adopter. The work is in `../zooid-native`; CVN stays where it is. Nothing here requires touching the framework — that is the test of whether the framework is one. ### 1. Implement `HostPorts` ```ts // zooid-native/src/cvn-host.ts import type { HostPorts, StorePort } from '@enc-protocol/cvn' import * as SecureStore from 'expo-secure-store' // or MMKV, AsyncStorage — the contract does not care import { getRandomValues } from 'expo-crypto' const store: StorePort = { get: (k) => cache.get(k) ?? null, set: (k, v) => { cache.set(k, String(v)); void SecureStore.setItemAsync(k, String(v)) }, remove: (k) => { cache.delete(k); void SecureStore.deleteItemAsync(k) }, keys: () => [...cache.keys()], getJSON: (k, fb) => { const r = store.get(k); if (r === null) return fb; try { return JSON.parse(r) } catch { return fb } }, setJSON: (k, v) => store.set(k, JSON.stringify(v)), } export function createNativeHost(): HostPorts { return { name: 'native', store, now: () => Date.now(), defer: (fn, ms) => { const t = setTimeout(fn, ms); return () => clearTimeout(t) }, every: (fn, ms) => { const t = setInterval(fn, ms); return () => clearInterval(t) }, fetch: globalThis.fetch, randomBytes: (n) => getRandomValues(new Uint8Array(n)), } } ``` Two things the contract insists on and native platforms get wrong by default: * **`store.get` is synchronous.** Native secure storage is async; the host owns a cache it warms at boot and writes through. A host whose `get` returns a Promise does not implement the contract, and the conformance suite says so. * **`every`'s stop must stop.** Background/foreground transitions on native tend to leave intervals alive or double them. The suite checks that a stopped interval does not fire again; the app's polls are declared once and this host runs them. ### 2. Run the conformance suite — before wiring any app ```ts // zooid-native/test/cvn-host.test.ts import { hostConformance } from '@enc-protocol/cvn/conformance' import { test } from 'node:test' // or vitest's — the suite takes what you give it import assert from 'node:assert/strict' import { createNativeHost } from '../src/cvn-host' hostConformance(() => createNativeHost(), { test, assert }) ``` Green here means the host does not lie about the contract. It does not mean the app works — that comes next, and it is the order that matters: a host bug found under an app looks like an app bug. ### 3. Supply the application's ports — and gate the contract An application on CVN declares more than `HostPorts`: its adapters, its crypto, its tables. In `impl-super-mvp` those are `ActionPorts` (≈50 names, declared as an interface) and the ports `ingestEvent` destructures. The native host supplies **exactly** that set: ```ts import { declaredInInterface, suppliedInLiteral, diffPorts, untravelled } from '@enc-protocol/cvn/gates' const declared = declaredInInterface(readFileSync('shared/kernel/run-action.ts', 'utf8'), 'ActionPorts') const supplied = suppliedInLiteral(readFileSync('src/native-ports.ts', 'utf8'), 'export const nativeActionPorts = {') assert.deepEqual(diffPorts(declared, supplied), { missing: [], extra: [] }) ``` Put this in the native repo's test tier. It is the check that found `dmEpochSet` — a port the kernel destructured and no host supplied — and it is cheaper than the week that defect spent inside a `catch`. Three rules for the ports you write, from [The rules](/rules): * **Data over behaviour.** If the kernel wants `needsEncryption(type)`, hand it the table and let the kernel apply the rule. Your host then has one fewer function to get wrong. * **No repackaging.** Do not collapse ports into one object to shrink the list; the count is of dependencies. * **Live accessors are functions.** `now`, `isDestroyed`, `identityPriv`, `boundEnclave` change during a session; hand them in as `() => value`, never as a captured value. The MVP's contract gate asserts this for its five accessors. ### 4. Build the runtime with the host's `performNow` ```ts import { createRuntime, router, performed, refused } from '@enc-protocol/cvn' const host = createNativeHost() const rt = createRuntime({ initial: bootModel, runAction: appRunAction, // the app's kernel runner — the same code web runs performNow: router({ submit: (cmd, _) => performed(adapters.submit(cmd)), pollNow: (cmd) => { polls.kick(cmd.id); return performed() }, persist: (cmd) => { host.store.setJSON(cmd.key, cmd.value); return performed() }, // Every kind the kernel emits, or the log gate refuses the run: // "[cmd] not performed: no port". }), onWarning: (m) => console.warn(m), }) ``` The router **refuses by name** a kind it does not know. Do not add a default arm that swallows unknown kinds; the whole point is that a Cmd nobody performs is loud. ### 5. Keep the log gate Native logs are not `browser.log`, but the rule transfers: after every run, refuse it on `is not a function`, `[cmd] not performed`, `unwrap failed`, `REJECT`. `@enc-protocol/cvn/tooling/log-gate` takes a file path; point it at whatever your native test runner captures. ### 6. What "done" is * `hostConformance` green in the native repo. * The port-contract check green: declared = supplied, nothing untravelled. * The app's node tier (no DOM) green driving the kernel with your host. * `rt.declaration.delegated` empty — nothing borrowed from a platform object the kernel reaches around the host for. None of these is a box someone ticks; each is a test that fails when it is not true. That is the difference between "ported" and "claimed ported", and it is the only difference CVN cares about. ## Node host ```ts import { createNodeHost, createMemoryStore } from '@enc-protocol/cvn/node' const host = createNodeHost() // memory store, node timers, global fetch const host2 = createNodeHost({ store: myStore }) // any StorePort — file, sqlite, yours ``` The store is in memory unless one is handed in. Timers and the clock are node's; randomness is `node:crypto`. This is the host the node test tier uses to drive a kernel with no DOM anywhere — the rule in the repositories CVN came from is **no DOM emulation in node**: a kernel that needs a document to be tested has a host leak. ## Web host ```ts import { createWebHost } from '@enc-protocol/cvn/web' const host = createWebHost() // localStorage, window timers, WebCrypto const host2 = createWebHost({ storage: sessionStorage }) ``` The store is `localStorage` unless a **Storage-shaped** object is handed in. That is also how the web host is tested in node without emulating a DOM: the contract is four methods (`getItem`, `setItem`, `removeItem`, `key`/`length`), and an object with those four *is* the dependency. If there is no `localStorage` and nothing is handed in, construction **throws** — a silent memory fallback would be a host that forgets on reload while claiming to persist.