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
// 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.getis synchronous. Native secure storage is async; the host owns a cache it warms at boot and writes through. A host whosegetreturns 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
// 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:
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:
- 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,boundEnclavechange 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
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 <kind> 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
hostConformancegreen 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.delegatedempty — 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.