Contributing
Operating rules (from PLAN.md §7, still the law)
- Read
packages/contractsbefore writing anything that touches shared shapes. Never change a contract without also updatingCHANGELOG-contracts.mdat the repo root — every entry there is additive by convention: existing shapes are never changed, only extended, so nothing downstream breaks silently. - Stay inside your owned directories. The original ten-slice build split the repo into disjoint
ownership (desktop, muse, twin, studio-ui, agents, skills, souls, market, generators, api-core,
landing, web-skills, docs-site, docs-content — see PLAN.md §7’s table); shared code goes through
packages/*only, never by reaching into another slice’s directory. - Cheap models by default.
strongis reserved for the Studio planner and QA reviewer only — and this is enforced structurally for generators, not just by convention: see Generators. - Beautiful by default: no unstyled fallback UI ships. Every empty/loading/error state in the web app has a real design, not a bare “Loading…” string.
- Secrets only in
.env; never commit..env.exampleis the tracked template; the real.envis gitignored.
Before you say you’re done
pnpm verifyis the whole of it, and it is what CI runs. Before it existed you had to know five commands and,
worse, know which two environment variables to set — and nobody set them, so two suites that guard
things which fail silently were being proved by nobody. scripts/verify.mjs runs these seven steps
in this order and prints a per-step timing table at the end:
| Step | What it runs |
|---|---|
typecheck | pnpm -r typecheck across all 12 projects |
test:root | vitest run over tests/** — every tripwire is in here |
test:packages | packages/{sandbox,skills} own suites |
test:chrome | the module isolation proof, in a real browser (NOVA_CHROME_TESTS=1) |
test:scaffold | the scaffold still builds with lifecycle scripts off (NOVA_SCAFFOLD_BUILD_TEST=1) |
build:web | apps/web production bundle |
build:docs | apps/docs static site |
About six minutes on a warm machine. pnpm verify --list prints the steps; pnpm verify --only=test
runs just the test ones. A failing step does not stop the run — you get the state of all seven, not
just the first thing that broke.
One prerequisite, and it is not optional
pnpm verify refuses to start without the WASM sandbox binary, and says so in as many words.
runtime/target/ is gitignored and runtime/wasm/*.wasm is 22 MB of third-party binary that is
deliberately not committed, so a fresh clone has neither — and WASM is the default sandbox tier.
Without it, three tests fail outright and sixteen sandbox containment attacks quietly do not run
while the file still reports “1 passed”. Build it once:
cargo build --release -p nova-wasm --manifest-path runtime/Cargo.tomlruntime/wasm/fetch.sh all # fetch.ps1 on Windows; version- and SHA-256-pinnedThe pieces, individually
pnpm typecheckruns tsc --noEmit (or the package’s own typecheck script) across every workspace package —
pnpm -r typecheck at the root. Each package defines its own typecheck script
(apps/api: tsc -p tsconfig.json; apps/web: tsc -p tsconfig.json --noEmit;
packages/contracts and the others: the same pattern), all extending one shared
tsconfig.base.json at the repo root (strict: true, ES2022 target, bundler module resolution).
No any in contracts — every entity, route input/output, and WebSocket event in
packages/contracts/src is a real Zod schema with an inferred type, never a loose escape hatch.
pnpm testruns the real automated suite: vitest run over tests/** — 1,752 tests in 109 files as of
7 September 2026: 1,725 passing, 27 skipped, none failing, in about 114 seconds — covering
contract invariants, the skill registry, the planner’s graph repair, the budget guards, deliverable
and build contracts, twin/studio integration flows, and — since the mesh landed — canonical JSON
against the published RFC 8785 vectors, signing and rotation, the sealed federation wire, Seed wire
sizes, moderation, and the directory’s boundaries. On top of that, packages/sandbox and packages/skills keep their
own suites deliberately outside the root collection (pnpm test:packages, 119 tests), because they
spawn real worker threads and subprocesses.
Of the 27 skipped, 12 are the Docker sandbox tests and that is correct — Docker was retired as a
tier in 03c0e54 and those probe for a daemon that is not there. The other 15 are the two gated
suites, which is why pnpm verify sets their flags rather than leaving them to memory.
What’s still missing is ESLint — despite PLAN.md §7’s Phase 0 line mentioning “eslint” as part
of the initial spine, there is no ESLint configuration anywhere in the repo, so pnpm typecheck and
pnpm test catch type errors and logic regressions, but not a style or lint pass. See
Roadmap & FAQ.
CI
.github/workflows/ci.yml runs on every push and pull request, on Node 22 (global WebSocket,
node:sqlite and Ed25519 in node:crypto all need it). Two jobs:
verify— the gate. Install, build the WASM runtime, typecheck, both test suites, the browser isolation proof, and both builds.scaffold-build— theNOVA_SCAFFOLD_BUILD_TEST=1suite, on its own because it is the only thing in CI that talks to the public npm registry at test time. Separate so that a registry outage reads as “the scaffold job is red” and cannot mask the state of the real suite.
Both gated suites run on every push, not nightly. A gate nobody sets is a test that does not exist, and burying one in a nightly job is most of the way back to that.
CI caches the pnpm store and the cargo target directory, and deliberately caches neither
node_modules nor vitest’s transform cache. Those are keyed on paths and mtimes; a stale transform
cache serves yesterday’s module for today’s source, and a green run that proves nothing is worse
than a slow one.
Two checks in that workflow are worth knowing about because they exist to catch a green result:
- After the gated suites run, CI asserts from the JSON report that they collected a non-zero number
of tests and skipped none. Both are built to skip quietly: with its flag off the browser proof
reports “1 passed” and a skipped test titled
NOT RUN, and with the WASM runtime missing the sandbox suite reports “1 passed | 16 skipped”. Both are green, and both are lies. vitest.config.tsrefuses to start at all if a tripwire file has been deleted or moved. The cheapest way to defeat an invariant test has always beengit rm: the suite goes green and the count drops by a few.
The tripwires
Seven of those tests are not ordinary regression tests. They exist because the failures they catch are invisible, and if one goes red the right response is to make the code right, never the assertion.
They also fail badly. expected [ …(10) ] to deeply equal [] is true and useless: it names neither
what broke nor why anyone cares. So tests/reporters/tripwires.ts prints, at the end of any run
where one of them fails, the invariant it guards and the opening comment of the test file itself
— written by whoever paid for the lesson. Not a second copy of the explanation, which would rot; the
file’s own words, read at the moment of failure. TRIPWIRES in that file is the registry, and
vitest.config.ts checks every path in it exists before collecting a single test.
| Test | What it protects |
|---|---|
tests/contracts/routes.test.ts | route-catalogue drift. It statically diffs packages/contracts/src/api.ts against every route registration in the API, and its exception list must stay empty |
tests/contracts/primitives.test.ts | every exported entity schema classified against the primitive ontology — a new schema that names no primitive fails |
tests/contracts/citizenship.test.ts | no capability may branch on being.kind. A human and an AI citizen get the same world |
tests/skills/plugin-contract.test.ts | the registry gates exactly the world-acting skills — no more, no fewer |
tests/db/single-owner-invariant.test.ts | one owner per node |
tests/identity/no-key-material.test.ts | no private key ever reaches a client — in any response body, including an error |
tests/modules/frame-isolation.chrome.test.ts | modules never run in the app’s origin — proved by driving a real Chrome, because jsdom implements no iframe sandbox and would pass for the wrong reason |
The last two are the ones whose absence is hardest to notice, because both prove that something
cannot be reached — a class of property that fails silently by definition. The browser proof is
also the one that spent longest being run by nobody: it is gated behind NOVA_CHROME_TESTS=1, and
until pnpm verify and CI set that flag, the single security property the whole module system rests
on had no automated proof at all.
Updating packages/contracts
- Add or extend a Zod schema in
packages/contracts/src/*.ts— additively. If you must change an existing field’s shape (not just add one), think hard about who else reads that row first. - Add the route to the catalogue in
api.tsin the same change. The catalogue is not documentation that follows the code; a route the catalogue does not know about fails the build. - Add an entry to
CHANGELOG-contracts.mdat the repo root, in the same terse style as the existing entries: what changed, the route or WS event affected, the exact new shape. - Run
pnpm typecheckfrom the root — a contract change that breaks another package shows up here immediately, since every package imports the contract types directly rather than duplicating them.
One trap that has cost real data
PATCH bodies use a purpose-built partial helper, never Zod’s .partial(). .partial()
leaves .default() in place, and a default fires on undefined — so a one-field patch parses into
a complete object, and the merge writes every other field back to its default. That emptied a
live account’s profile, across five routes, before anyone noticed. If you are adding a PATCH,
look at how the neighbouring ones do it.
Database changes
pnpm db:push # apps/api's schema push, from the rootSchema lives in apps/api/src/db (Drizzle ORM over better-sqlite3). db:push runs
tsx src/db/push.ts (apps/api/package.json), not drizzle-kit push directly: it calls the exact
same initDatabase() the server runs at boot — Drizzle migrations plus the raw FTS5 virtual table
and triggers drizzle-kit push doesn’t know about — so it’s idempotent and non-interactive, safe to
run repeatedly, and never blocks on the confirmation prompt drizzle-kit push would raise when it
sees FTS5 objects it doesn’t recognise and proposes dropping them. There’s no migration history file
today; schema changes should be additive and backward-compatible with existing rows wherever
possible, the same discipline as the contracts changelog.
Skills and generators
- A new skill is a
defineSkill({ name, description, category, input, output, run })call (packages/skills/src/registry.ts) registered intoCORE_SKILLS(or a Twin-specific registry extension). Itsnameshould be added toBUILTIN_SKILLSinpackages/contracts/src/skills.tsif it’s meant to be usable from a generator step — a step referencing a skill outside that list fails generator validation. - A new generator follows the authoring kit in
apps/api/src/studio/generators/define.ts(form(),str()/text()/num()/bool()/pick()for the input schema,step()for each pipeline stage) and must passvalidateSeedGenerators()— unique ids/slugs, every skill real, everydependsOnresolved, no step on thestrongtier, every declaredoutputKindactually produced. Add it toSEED_GENERATORSinapps/api/src/studio/generators/index.tsand to the Generators catalogue here in the docs.
Documentation
Docs content lives in docs/content/**/*.md and is deliberately decoupled from the site that
renders it (apps/docs, Astro + Starlight) — writing a page here never requires touching the site
build. Match the existing house style: frontmatter with title + one-sentence description, plain
prose grounded in real code (a real request/response, a real config snippet, a real skill
invocation), and — where PLAN.md describes something the code doesn’t actually do — say so plainly
rather than describing the aspiration as if it shipped. Every numbered section directory needs an
index.md; sub-pages follow the section’s own established naming pattern (check the directory
before inventing a new one).