Are you an LLM? Read llms.txt for a summary of the docs, or llms-full.txt for the full context.
Skip to content

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.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

// 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, 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

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

  • 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.