Skip to content

Boundaries

Generated from docs/architecture/boundaries.md when the site was built at 7aa23e2.

Status: audit, to decide what the architecture work adds. It describes origin/main at 621af1a6 (“Merge arch-6d-traits”) and notes how the unmerged branches change each boundary: arch-6c-gen (61c40bdd), arch-6c-glue (71a2d1dc, which contains arch-6c-gen), arch-host-prep (aa5cc49f), node-windows (90a5e8e7) and electron-compat (6c7f9f98). There is no electron-compat-tests branch on origin; Electron’s tests live on electron-compat. Paths are relative to the repository root and line numbers are at main unless a branch is named.

“Contract” means a written statement a caller can rely on: what the boundary is, who owns what crosses it, errors, threads, reentrancy, versioning and how stable it is. “Machine-checked” means something fails when the boundary changes or an implementation breaks it: the boundary is generated from one source, a snapshot or golden is compared, or a contract suite runs against every implementation. A test that happens to call part of a boundary is noted but does not count.

niso has more boundaries than written contracts, and fewer machine checks than either. The foundation crates (niso-base, niso-host, niso-sched, niso-storage, niso-structured-clone) are well defined: real CONTRACT.md files, numbered guarantees and contract suites that run against every in-repo host or driver. Almost everything else is defined by its code. The biggest owner of boundaries, niso-core, has no contract at all, though it owns 11 of the 18 C header sections, the render stream, the template format, the platform CSS tokens and the 6d host traits. The C ABI has no version. Nothing checks any crate’s public Rust API, the two composed headers, the render stream’s readers, the hosts, the glue’s module boundary or the tooling formats. architecture.md 8.4’s enforcement (cargo public-api diffs, header diffs, NISO_<CRATE>_CONTRACT macros, “CI refuses a public API diff without a CONTRACT.md diff”) does not exist, and there is no CI: tests/check.sh is the only gate, and someone has to run it.

The pattern behind most gaps is the same: a list kept by hand in two to eleven places with nothing comparing the copies. The hook names live in six places, the render ops in eleven readers, the template constants in three, the JNI names in two, the glue features in three manifests, the probe keys in four writers and the harness flags in three spellings. Where niso already has one source (the IDL for niso.h and its Rust shims, status.h for ErrorCode), the boundary holds.

# Boundary Contract Machine-checked Owner Worst gap
1a Public Rust API of the contracted crates (base, host, sched, storage, structured-clone, xpath, canvas, compose; xml outside the template) partial no (behaviour suites only) each crate No surface check at all: a removed or changed pub item that no test calls passes everything
1b niso-core’s public Rust API, used by niso-capi, every binding, niso-android, niso-actions, hosts/iced and niso-wpt no no niso-core A 4,238-line public surface with no CONTRACT.md, so by architecture.md 2.1 it is all “internals”, yet ten crates reach in
2a C ABI, generated half (niso.h, 1,627 functions) partial partial (one source for prototype and shim; no golden on main) niso-idl and the IDL owners No per-function docs; whether a callback’s drop runs on error depends on argument order
2b C ABI, hand-written half (niso_runtime.h, 127 functions in 18 sections) partial partial (status codes, render version) per section, sections.txt niso_dom_last_error never cleared on success; 29 functions crash on a NULL DOM
2c ABI version and stability no no nobody No ABI version, SONAME or app/host handshake
3a Render stream (rendering.h, version 2) partial no niso-core Ops carry no length so readers cannot skip; the three production hosts never check the version; no shared fixture
3b Template format (widgets, Live Activities, CarPlay) yes partial (encoder only) niso-core, later niso-surfaces Already broke once inside version 1; the Swift and Kotlin decoders are never tested against the encoder
4a Storage Driver and its C vtable; niso-host Wake yes yes (suites on every driver and on the C adapter) niso-storage, niso-host No host ships a C driver, so the adapter’s only implementer is the test vtable
4b 6d traits: DocumentHooks, ResourceLoader, WorkerHost partial (rustdoc in niso-core) partial niso-core The suites run a fixed recording implementation, so no binding can run them on its own hooks
5a niso-host HostServices, Cx, Registrar yes yes for behaviour (host, tree, wake suites on FakeHost, SimpleHost and the document) niso-host Registrar has no way to register a Source, which wave 1 needs and wave agents may not add
5b niso-sched Source, LoopHost yes partial (source_suite on 2 of the core’s 6 sources) niso-sched Obligations on sources only partly checkable
6 Engine binding interface (niso_engine.h, niso_js.h, engine extensions) partial (header comments) no bindings/js Hook names written six times; no suite runs against every engine
7 JS glue module boundary no on main; partial on 6c-glue no glue core, each crate Optional sections are used by required ones: two optional features fail with ReferenceErrors
8 WebIDL as source of truth partial partial niso-idl niso-idl has no tests on main; duplicates and unknown extended attributes are accepted silently
9a niso_app.h partial partial (examples compile it) niso-app No version handshake on a library loaded with dlopen
9b Swift host (hosts/apple) no no (device runs only) hosts/apple No reusable API; decodes the stream and templates by hand
9c Android JNI (niso-android) and the :niso Kotlin library partial (KDoc) no niso-android, hosts/android 33 names kept by hand on both sides, no panic boundary, errors dropped
9d JVM API (dev.niso.dom) partial (Javadoc) partial (generated; RegisterNatives at load) niso-jvm 34 runtime natives written twice
9e iced host no (a program) partial hosts/iced Bypasses the C ABI through niso-core internals
9f Surface names (widget, island, CarPlay) partial no niso-core and each host Hosts use names the header does not list (island.expanded, carplay.list)
9g Platform CSS prelude and --niso-* tokens partial partial (two core tests) niso-core No list of what each host must set; tvOS, watchOS and macOS leave tokens unset
9h niso HTML attributes and meta partial partial niso-core Decided extensions (--niso-text-engine, @media (platform)) are absent from the code
10a Node Page API (niso.js) partial partial (behaviour tests) niso-napi No package boundary: no name, exports or types; the Electron shim uses internals
10b Window API (node-windows) partial (branch doc) no niso-napi window feature Untested, and named as the Electron shim’s future public surface
10c Electron shim (electron-compat) partial partial (7 fixtures) bindings/js/electron Export list hand-typed, not compared with Electron’s
11a CLIs: niso-run, niso-iced, niso-node, niso-wpt partial no each binary Typos become script paths; docs drift from parsers
11b probe.json and harness payloads no no tests/screens Four writers, already disagreeing on keys
11c WPT and test262 expectation files partial partial niso-wpt A missing file silently means “expect nothing”
12 Persisted formats (SQLite schema, key encoding, structured-clone bytes) partial partial niso-storage, niso-structured-clone SQLite files carry no schema version
13 niso-intl C ABI (niso_intl.h) partial no (test262 indirectly) niso-intl No contract; status codes of its own
14 Other byte formats in the ABI (net responses, module values, media actions, surface list, --render files) partial no their sections Prose only, unversioned
15 Native modules ([NisoModule], niso_module_*) partial (docs/modules.md) partial (tests, examples) niso-modules (step 15) The generated JS, .d.ts and Java have no snapshot
16 Build-time composition (sections.txt, layer metadata, glue-* features) yes yes (compose unit tests, layers lint) niso-compose, xtask The lint checks rule 6 by the presence of a CONTRACT.md file
17 Environment variables partial no each script No index
  1. No enforcement point. There is no CI (architecture.md:45 says so; git ls-files shows no .github or other CI configuration). tests/check.sh runs the layers lint, cargo test --workspace, Miri on niso-sched and niso-storage’s suite, the language examples (each skipped when its toolchain is missing), WPT, test262 and Acid. Device and host checks are opt-in flags. A check proposed below protects anything only once some gate runs it before a merge.

  2. architecture.md 8.4 is unimplemented, and no migration step owns it. No cargo public-api diff, no header diff, no NISO_<CRATE>_CONTRACT macros, no NISO_HAS_<NAME> (architecture.md:480); grep -rn public-api finds only prose. migration.md schedules none of it: no step or wave names an owner.

  3. Contract versions have no rule behind them. Each CONTRACT.md carries version: <n> (niso-base, -host, -sched, -storage, -structured-clone and -compose at 1; -canvas and -xpath at 2; niso-xml none). The template says “bumped on any breaking change” without defining one, and no contract has a changelog. On the branches, arch-host-prep adds about 50 items and nine guarantees to niso-host, arch-6c-gen makes capi::call and capi::nested generic over their result and adds guarantee 22, and both leave version: 1; arch-6c-gen adds bindings to niso-xpath and keeps version 2. That is defensible only if additions and widened signatures are not breaking, which nothing says. Every crate is version = "0.0.0", publish = false, so Cargo’s semver says nothing either.

  4. The template has no Errors or Stability section. architecture.md 8.1’s template covers purpose, surface, host requirements, dependencies, guarantees, threads, performance, fakes and standalone use. Error behaviour is scattered through the guarantees, and nothing says which parts of a surface are stable, experimental or test-only.

  5. Suites check behaviour, not surface. The foundation suites are good (2.5), but a renamed, removed or re-typed pub item that no test calls passes all of them.

  6. Rule 6 is checked by file presence. cargo xtask layers lets a binding depend on a crate once that crate has a CONTRACT.md (xtask/src/main.rs:120-128). Whether the binding uses only items the contract names is not checked: a binding may call niso-storage’s unlisted Range::pair_bounds and pass.

  7. The crates that own the most boundaries have no contract. niso-core (C sections, render stream, templates, CSS tokens, DocumentHooks, ResourceLoader, WorkerHost), niso-capi (the C ABI crate, which globs use niso_core::*, crates/niso-capi/src/lib.rs:9), niso-idl (which generates the C ABI, the JS classes, the JVM classes and, on arch-6c-gen, every Rust facade), niso-app, niso-android, niso-napi, niso-jvm, niso-quickjs, niso-porffor, niso-crypto (a pinned web-api crate, against migration rule 5), niso-actions, niso-intl, bindings/js and every host. Nine crates have one: base, canvas, compose, host, sched, storage, structured-clone, xml and xpath.

  8. Two production hosts bypass the C ABI. hosts/iced and niso-android call niso_core::Dom directly and discard its errors (let _ = unsafe { Dom::update_rendering(self.js.dom()) };, hosts/iced/src/engine.rs:114; 20 let _ = in crates/niso-android/src/lib.rs). The call discipline DECISIONS.md requires (“every entry point keeps the same call discipline”) applies to Swift, the JVM, Node and native apps, but not to Linux or Android.

  9. #[doc(hidden)] is unused on main, though the template says items that are not API are “pub(crate) or #[doc(hidden)]”. The first two arrive on arch-host-prep (AlgorithmId::new and AlgorithmId::get, crates/niso-host/src/abort.rs:19-28 there), and both hosts must call them. rustdoc JSON, and so cargo public-api, omits hidden items, so the surface check proposed below would not see them change.

  10. A wording conflict to settle. DECISIONS.md, Process: “No generated source, no clever glue.” The architecture generates the C shims, the facades and the composed files, and the checks below add committed snapshots and goldens. Read as “no generated source checked in as source”, the two agree; the sentence should say so, so that a committed golden is not read as generated source.

2.1 Each crate’s public Rust API (1a, 1b)

Section titled “2.1 Each crate’s public Rust API (1a, 1b)”

Where. Each crate’s src/lib.rs and the modules it makes pub. The cargo-public-api prototype (section 5) measured the surfaces in lines of snapshot: niso-base 318, niso-host 532 (779 with contract-tests), niso-sched 288, niso-storage 596, niso-structured-clone 144, niso-xpath 126, niso-xml 137, niso-canvas 862, niso-compose 60, niso-idl 509, niso-crypto 62, niso-intl 22, niso-actions 111, niso-capi 1,718 (almost all the extern "C" shims) and niso-core 4,238 (1,434 niso_core::api::* functions, 130 Dom methods).

Contracts. The nine CONTRACT.md files name their surfaces, and most match the code. Gaps found:

  • niso-host: Cx::new and ExtKey::new/name are pub and used but not named. InterfaceInfo { pub name } (crates/niso-host/src/lib.rs:34-36), EventInit, Dispatched and TimerId(pub u64) (:57) are all-pub-field types without #[non_exhaustive], so architecture.md 3.1’s planned id, parent and exposed fields would break every literal (crates/niso-xpath/src/api.rs:16-18, crates/niso-canvas/src/objects.rs:21-26).
  • niso-sched: pub use niso_host::{Cx, HostServices, Result, Task, TaskSource, TimerId} (crates/niso-sched/src/lib.rs:22) is unlisted; Scheduler::set_task_hook takes a raw *mut c_void (:408); guarantee 8 of architecture.md 8.2 (“run inside a task returns InvalidState”) is “not refused yet” (CONTRACT.md:214-215).
  • niso-storage departs from architecture.md 8.3 without saying so in its “Differences” section: no sqlite feature (rusqlite is unconditional, crates/niso-storage/Cargo.toml:19), ffi::CDriver instead of FfiDriver, key::Key instead of key::{encode, decode, compare}, and Driver is not Send (crates/niso-storage/src/lib.rs:233) where the architecture says it is. Unlisted pub items: key::MAX_DEPTH, Range::{pair_bounds, key_bounds, contains}.
  • niso-base: architecture.md 2.2 and 3.1 put InterfaceInfo, InterfaceId and Callback in niso-base; none is there (niso-host, niso-core and niso-idl hold them). The contract records two of the three; the architecture is stale.
  • niso-canvas exposes representations niso-core reaches through: Path2D(pub Path), CanvasGradient(pub Rc<RefCell<Gradient>>) and CanvasPattern(...) (crates/niso-canvas/src/objects.rs:33-69, used as .0.borrow_mut() at crates/niso-core/src/api/canvas.rs:773, 784). It re-exports kurbo and vello_cpu::Pixmap, which makes two third-party versions part of its API with no rule for upgrading them.
  • niso-xml’s CONTRACT.md has no version line, Public surface, Threads or Fakes section. Pub with no outside user: is_char, is_name*, NotWellFormed and the amplification constants (crates/niso-xml/src/lib.rs:23, 117-138). It is not in xtask’s PINNED list, so its layer is self-declared.
  • niso-crypto (30 pub functions taking algorithm names as &str), niso-idl, niso-actions and niso-intl have no contract.

niso-core. Seventeen pub modules, eighteen root re-export lines and Dom with about 129 pub methods (crates/niso-core/src/lib.rs:36-84, 283). Who reaches in:

Module Used by Note
api niso-capi’s shims (mod api { pub use niso_core::api::*; }), niso-actions, niso-napi, niso-quickjs (boxes.rs, page.rs) the bindings’ uses are the allowlisted rule-6 exceptions
ffi niso-capi; niso-actions (call, call_reentrant); niso-napi; niso-quickjs the call bracket helpers are pub only for these
host niso-capi/src/xpath.rs (DomHost::new(dom: *mut Dom))
controls, input niso-android, niso-capi, hosts/iced, niso-actions host-facing style structs and constants with no contract
render niso-capi (VERSION), niso-quickjs/src/boxes.rs (use niso_core::render::*), compiled into niso-napi by #[path] the allowlist entries for quickjs and napi do not mention it
storage, structured_clone niso-capi, quickjs tests migration re-exports
net niso-wpt (wpt_*, fetch::ReferrerPolicy) test hooks
canvas nobody: a pub mod whose items are all pub(crate) a dead pub
media, template nobody outside niso-core’s tests

niso-capi’s glob import puts every new root item of niso-core in scope of the C API crate, and nothing records which it uses. niso-android is a composition (architecture.md 2.1 rule 5: “no hand-written logic beyond entry points”) with 515 lines of JNI over core internals, and xtask’s forbidden has no case for compositions.

Machine checks. None for surface. Behaviour suites: 2.5.

Branches.

  • arch-6c-gen adds pub mod bindings to niso-core and niso-xpath: one module per interface, one function per member, each entering through the bracket. niso-core’s facade is pub unsafe fn <member>(dom: *mut crate::Dom, ...), so the API architecture.md 7.1 tells bindings to use is unsafe and names niso_core::Dom; niso-xpath’s is safe, over &dyn HostServices. Facade arguments carry C value types (NodeOrString, BytesOrStr<'_>, Held, *mut c_void for callbacks), and a non-core facade panics in its build script on them (“a crate’s facade cannot take {name} yet: it is niso-core’s”), so only niso-xpath can have one today. pub mod api stays public beside bindings, so every member has two public entry points and the api one skips the bracket (no epilogue, no safe point). InterfaceId changes from an enum to a u16 newtype, a breaking change to a pub type with no contract to bump. cargo-public-api measures niso-core’s diff at +1,940/-189 lines.
  • arch-host-prep grows niso-host by about 50 items, with the contract changed in the same branch (the discipline 8.4 asks for, done by hand) and platform_suite run by FakeHost and the document.

Where. niso.h is generated by niso_idl::c_header (crates/niso-idl/src/emit.rs:697-762) from the IDL, and its Rust side by rust_shims (emit.rs:616-660) into niso-capi’s OUT_DIR. niso_runtime.h is composed by niso-compose from 18 section files (crates/niso-compose/sections.txt:32-52) inside a frame (crates/niso-compose/frame/head.h, tail.h); its Rust side is hand-written in crates/niso-capi/src/{lib,wasm,clone,xpath}.rs. Neither header is in git: both are written to OUT_DIR and published as DEP_NISO_INCLUDE, where the bindings, the Apple host (hosts/apple/build.sh:105-138) and examples/lang/capi.sh find them.

Contract. The whole ABI-wide contract is head.h:1-24: one thread (the creator’s), UTF-8 plus length, out-parameter data “valid until the next call into the same DOM”, 0 or a niso_status with the message in niso_dom_last_error, generational handles, callbacks dropped exactly once, dictionaries append-only. Sections add local rules. Holes, each verified in the code:

  • Errors. niso_dom_last_error is per DOM (Dom::last_error), set only on failure and never cleared on success (crates/niso-core/src/ffi.rs:10-25: Ok(Ok(())) => return 0 comes before the assignment), not set by the query-style functions (ffi::query, ffi.rs:48-53) or by the 52 runtime functions that return something other than a status, and the lifetime of the string it returns is unwritten. A failure nested in a listener leaves its message behind after the outer call succeeds. head.h’s “Functions return 0 on success or a niso_status” is false for those 52.
  • NULL. 29 hand-written runtime functions dereference dom with no check and outside any panic boundary (crates/niso-capi/src/lib.rs:41-875, for example niso_dom_last_error at :71-74), where generated shims return NISO_INVALID_HANDLE. Generated shims tolerate NULL out parameters; hand-written ones write through them (niso_render_frame, lib.rs:309; niso_dom_tap, :346). The header says neither.
  • Ownership on failure. Generated shims build a callback’s owning struct in parameter order, so whether drop runs when a call fails depends on which argument failed first (invalid UTF-8 in type comes before the listener in niso_event_target_add_event_listener). head.h says the DOM calls drop exactly once; storage-config.h:21-23 says the caller keeps the driver on error. niso_event_target_remove_event_listener takes a drop function whose meaning is unwritten. Callback getters return void** out (emit.rs:440) with no stated lifetime.
  • Threads. One sentence in head.h, contradicted by exceptions scattered over five sections (the wake hook, niso_module_settle and emit, structured clone, worker begin and end, the worker host). No function checks the calling thread. DECISIONS.md says “The core stays Send”, but Dom holds Rc scratch buffers (crates/niso-core/src/lib.rs:367-368) and is not Send.
  • Reentrancy. Per-hook notes only. That a listener may call any function and each nested call runs its own epilogue is written in architecture.md 7.1 and, on arch-6c-gen, niso-host’s guarantee 22, not in the header a C caller reads. niso_dom_run from inside a task is not refused.
  • Versioning. None. The only version in the ABI is NISO_RENDER_VERSION: no ABI version macro or function, no SONAME, no export list. Interface ids are positional on main (crates/niso-idl/src/model.rs:269-271), so adding an interface renumbers the later NISO_IFACE_* values that hosts and app libraries compile in.
  • Documentation. niso.h’s 2,515 lines carry one comment, the “Do not edit” banner.

Machine checks. Generated prototypes and their Rust shims come from the same tuples (emit.rs:400-418), the one property guaranteed by construction. ErrorCode against status.h (crates/niso-base/tests/contract.rs:8). NISO_RENDER_VERSION against niso_render_version() (crates/niso-capi/tests/render_version.rs). The headers compile as C in niso-capi-tests and with -Werror in examples/lang/c. A 565-line C smoke test (crates/niso-capi-tests/c/c_api_test.c) calls 31 of the 127 runtime functions and 48 of the 1,627 generated ones. Not checked: any golden of either header; the 127 hand-written prototypes against the Rust exports (they match today, 127 of 127 with no scalar mismatch, by a one-off script); and DECISIONS.md’s “conformance suite runs against the C API”, which does not exist. 36 runtime functions are called by no C file in the repository; their prototypes are compiled only by the Swift host, on macOS.

Owner. Per section in sections.txt, the one machine-read ownership record. niso-core owns 11 of the 18 sections and has no contract. Nobody owns the frame rules or ABI versioning.

Branches.

  • arch-6c-gen hashes interface ids (FNV-1a kept to 30 bits, collisions and 0 refused; crates/niso-idl/tests/ids.rs), so ids stop moving. niso_object_interface goes through ffi::query. The bracket gets a written, suite-checked nesting rule (guarantee 22, bracket_suite on FakeHost and the document), and crates/niso-capi/tests/shim.rs is the first test of last_error on the C path. It adds the repository’s first header golden, crates/niso-idl/tests/c_header.main.txt (2,516 lines), but it is temporary by its own doc (“this test goes once 6c-gen is merged, after which a header change is reviewed as the change to the IDL that made it”, tests/header.rs:1-5). It also sorts lines, so it cannot catch a reordered dictionary field (the one change head.h forbids), and it covers niso.h only. Error semantics are unchanged, and 28 runtime functions still dereference dom unguarded.
  • arch-6c-glue adds niso_dom_set_navigator_platform and niso_dom_navigator_platform, whose strings “stay valid until they are set again” (a third lifetime rule) and whose getter dereferences dom unguarded. No host calls them yet.

2.3 The render stream (3a) and the template format (3b)

Section titled “2.3 The render stream (3a) and the template format (3b)”

Where. Spec: crates/niso-core/include/rendering.h:1-288, prose. Writer: crates/niso-core/src/render.rs (VERSION: u32 = 2 at :35, op constants at :37-51). Little-endian, 15 ops (FONT 1 to EFFECTS 15), each frame a delta on the last.

Contract. There is a real versioning rule (rendering.h:270-275): ops and fields only grow at the end, the version goes up with each, and a host reads no other version. But ops carry no length, there is no stream header, magic or sequence number, and some ops have conditional layouts (TEXT_FIELD kind 0, CONTROL kind 5, IMAGE’s maximum id). So a reader cannot skip what it does not know, every bump is a hard break for every reader, and a dropped or reordered frame corrupts the reader’s tree without notice. FONT and IMAGE data come from side calls (niso_render_font, niso_render_image), so the stream is not self-contained. niso-core documents every op in rendering.h; nothing says who allocates op numbers once L2 crates contribute render output through RenderContributor (migration steps 18a-c).

Readers. Eleven, each transcribed by hand from the prose:

Reader Unknown op Checks the version
crates/niso-core/tests/render.rs (the writer’s test reader) panic n/a
crates/niso-quickjs/src/boxes.rs (also compiled into niso-napi) panic no
hosts/iced/src/engine.rs:177-296 panic (:296) no
hosts/apple/Sources/Engine.swift:584-663 fatalError (:663) no
hosts/android/niso/src/main/kotlin/dev/niso/host/Engine.kt:390-448 error (:448) no (the JNI side exposes no version)
examples/lang: C, Go, Zig, Rust, Java exit or error yes (Java against a hard-coded 2)

Machine checks. The writer’s own reader; the five language examples read the timer app’s live frames, so an op the timer never emits is never read. There is no shared fixture and no test of the Swift or Kotlin readers (neither host has a unit test), and the production hosts use bare numeric op literals.

Templates. rendering.h:290-410 is the best-specified format in the repository: magic NT, a version byte, a len:u16 per node so decoders skip appended fields, unknown kinds drawn as a box, and a compatibility section. It already broke once inside version 1 (rendering.h:406-410 says so). The encoder truncates silently: self.body.len() as u16 is unchecked (crates/niso-core/src/template.rs:141) and children past 255 are dropped (:143). The Swift (hosts/apple/Shared/Template.swift) and Kotlin (Template.kt) decoders copy the constants and are never fed the encoder’s output; the only decoder under test is a test-only Rust one (crates/niso-core/tests/template.rs:93-150). The ActivityKit 4 KB budget lives in a test, not in the spec.

Branches. node-windows feeds niso_render_frame to the iced reader from Node and records a real bug the delta stream caused: frames applied after the window’s own update “undid them (a textarea’s value went back, and typed keys were lost)” (crates/niso-napi/src/window.rs:164-169 there). electron-compat’s design runs documents out of process, with the host “drawing from the render stream” (docs/design/electron.md), which turns the missing framing, lengths and in-band version into real problems.

2.4 Host-provided traits and their C adapters (4a, 4b)

Section titled “2.4 Host-provided traits and their C adapters (4a, 4b)”

Where. niso_storage::Driver (crates/niso-storage/src/lib.rs:233) with ffi::CDriver; niso_host::Wake (crates/niso-host/src/waker.rs:17) with CWake; DocumentHooks (crates/niso-core/src/hooks.rs:104) with CDocumentHooks; ResourceLoader (crates/niso-core/src/loading.rs:198) with CLoader; WorkerHost (crates/niso-core/src/worker.rs:56) with CWorkerHost. The C typedefs are in include/hooks.h, loading.h, worker-hooks.h, event-loop.h and storage-driver.h.

Contract. Driver and Wake are in their crates’ CONTRACT.md files with numbered guarantees (Wake has W1-W7). The three 6d traits are documented in rustdoc: hooks.rs:1-40 is “the contract”, with five guarantees, and covers ResourceLoader and WorkerHost by reference. No version and no Threads section. ResourceLoader is Send + Sync but documented as called on the document’s thread only, and its C form’s bytes are valid “until the loader is called again”.

Machine checks. architecture.md 7.5’s template works for the two foundation traits: niso_storage::contract::suite(make) runs on the memory and SQLite drivers and on CDriver over each (crates/niso-storage/tests/contract.rs:34-56, also under Miri), and wake_suite(make) on a Rust wake, the C adapter and the document. The 6d suites have a different shape. document_hooks_suite(way: Way), resource_loader_suite(way) and worker_host_suite(way) (crates/niso-core/src/contract.rs:256, 671, 775) take only Way::Rust or Way::C and build their own recording implementation. They test that the document calls a correct implementation correctly; they cannot test that an implementation is correct. architecture.md 7.5 asks for suites “against every implementation”, and step 19 plans each binding to run them on its own hooks, but as written niso_js.c’s hooks, Node-API’s and the JVM’s can never be passed in.

Gaps. No host ships a C storage driver, so CDriver’s only real implementer is the test vtable. The 6d traits are pub with no implementor outside niso-core’s contract.rs, and they live in niso-core, which has no CONTRACT.md to version them.

2.5 niso-host and niso-sched: the contract web API crates run against (5a, 5b)

Section titled “2.5 niso-host and niso-sched: the contract web API crates run against (5a, 5b)”

Where. crates/niso-host/src (lib.rs: Cx, HostServices, PlatformObject; state.rs: HostState, Registrar, ExtKey; capi.rs: the bracket; tree.rs: Tree) and crates/niso-sched/src (Source, LoopHost, Scheduler, SimpleHost).

Contract. niso-host’s CONTRACT.md (428 lines) is the best in the tree: borrows, panics, timers, teardown, the tree, 21 guarantees plus T1-T12, each named after the suite function that checks it, threads and performance. niso-sched’s (257 lines) is as careful and records where the code departs from architecture.md 8.2.

Machine checks. host_suite runs on FakeHost, SimpleHost and the document; tree_suite on FakeHost and the document; scheduler_suite on the fakes; source_suite on the fakes and on two of the core’s six sources (modules and messages, crates/niso-core/src/event_loop.rs:507-508). This is the one place where “a contract suite runs against every implementation” is true.

Gaps.

  • No route from a crate to the scheduler. Registrar has interfaces, ext and checkpoint_end on main, plus rendering_step and worker_seed on arch-host-prep and call_bracket on arch-6c-gen. None registers a Source. niso-sched’s contract says “The Registrar route comes with the first crate that does (step 7)”. Step 7 is wave 1’s websocket, migration.md says “No wave agent edits niso-host, niso-sched, niso-idl or niso-compose”, and host prep’s list does not include it. niso-sched depends on niso-host, so Registrar cannot name Source directly; this needs a design decision (a type-erased registration in niso-host that niso-sched drains, or an extension trait in niso-sched) before wave 1 starts.
  • Cx::host_any lets a crate downcast its host. The contract forbids crates from calling it and says “No lint checks it; review does” (requirement 8).
  • HostServices’ new methods on arch-host-prep default to NotSupported or “null”. A host that forgets one degrades silently unless it runs platform_suite, and nothing makes a host run it.
  • source_suite does not run on the core’s websocket, fetch, crypto and rendering sources; the contract says when each will.
  • Guarantee 7 (no allocation in ready) is checked on SimpleHost only; the document’s sources take locks there.

Where. bindings/js/niso_engine.h (139 lines: what an engine implements), niso_js.h (the embedder API), niso_js.c (1,291 lines: the engine-neutral core) and the adapters: bindings/js/quickjs/qe_quickjs.c, bindings/js/porffor/qe_porffor.c with prelude.js, and crates/niso-napi/src/engine.rs. The JVM binding (bindings/jvm/niso_jvm.c and dev.niso.dom) is a Java DOM API over the C ABI, not a qe_* engine.

Contract. Header comments: “Every function here runs on the DOM thread” (niso_engine.h:7), and ownership of strings passed into host functions, of qe_bytes and of qe_ret_alias. Missing: a version; reentrancy (hooks run while host functions are on the stack, and niso_js.c tracks depth for it); ownership of qe_ret_str’s input and of the hook argument array; one error convention (three coexist: qe_eval 0 or nonzero, qe_eval_args 0, 1 or 2, qe_hook NaN); and the engine extensions: host.namedProperties and host.checkFunctionBody (QuickJS), host.adopt, host.wrapper and host.engineWork (Node-API). The glue feature-detects all of them except host.engineWork, which it calls unguarded on a path any engine with its own WebAssembly takes (crates/niso-core/glue/wasm.js:816-823). The error and report hooks are used by name and absent from the header. Capabilities are signalled three ways (qe_can_alias, qe_runs_workers, feature detection), with no list.

Hook names, six copies. enum qe_hook (niso_engine.h:20-36); qe_hook_names, whose order must match the enum and whose missing entry compiles to NULL (niso_js.c:48); the glue’s returned object with 15 keys (crates/niso-compose/frame/tail.js); Node-API’s hand-copied HOOK_COUNT = 13 and name table (engine.rs:49-56); Porffor’s positional __niso_bind(...) (prelude.js:67-68); and Porffor’s C enum extension and positional array (qe_porffor.c:46, 72-83), which returns silently on a mismatch. This has broken before (docs/engines/porffor.md:248-249, 446-450).

Machine checks. None across engines. QuickJS has 21 integration test files. Porffor include!s four of them behind cfg(niso_porffor), set only when NISO_PORFFOR_SCRIPTS is, which tests/check.sh never sets. Node-API runs a hand-ported bindings/js/napi/test.js under node, deno and bun (the last two skipped when absent). The JVM has examples only. niso-wpt can run Node, but check.sh runs QuickJS only. DECISIONS.md’s “Every engine passes a conformance smoke test that runs in seconds” does not exist. Listener cycles leak on every engine; the only test is #[ignore]d (crates/niso-quickjs/tests/platform.rs:504).

How engines reach niso. niso_js.c and niso_jvm.c use the C ABI only. niso-quickjs, niso-porffor and niso-napi use niso-core internals (allowlisted until step 19), and two of them compile another crate’s source with #[path] (niso-porffor’s lib.rs is niso-quickjs’s; niso-napi includes boxes.rs).

Branches. arch-6c-glue removes the enum and the name array: qe_hook(engine, const char* name, ...), each engine caching hooks by name, and Porffor’s hooks are no longer positional. The names still live in two places (C string literals and the JS onHook calls), and a typo or a missing module now returns NaN, which the message hook reads as “cannot deserialize” and the wasm call hook as “threw”. Nothing enumerates the names C calls and checks that the glue returns each. node-windows makes niso-napi depend on the iced host (a new allowlist entry niso-napi -> niso-iced), deepening the binding’s reliance on internals.

Where. 39 sections in crates/niso-compose/sections.txt:54-98, composed in the monolith’s order into one function body, from bindings/js/glue (the glue core), crates//glue and crates/niso-compose/frame. The optional sections form-data xhr dom-parsing file-reader atob (sections.txt:59) each sit behind a glue-<section> Cargo feature, declared separately in niso-quickjs, niso-napi and niso-porffor.

Contract. None on main. architecture.md 7.3 says “The glue core’s ctx is itself a contract (bindings/js/CONTRACT.md)”; that file does not exist on main.

What crosses. Every section shares one lexical scope, so any section reads and writes any other’s names. Verified cross-owner and cross-optionality uses include: required fetch-bodies calls parseMimeType from optional xhr and constructs NisoFormData from optional form-data (crates/niso-core/glue/fetch-bodies.js:94-95); optional xhr calls decodeWith and encodingFor from optional file-reader (xhr.js:260-309); the glue core’s exceptions.js reads wasm’s WA_ERRORS; messaging writes secure-context’s let workerOwnerSecure; the tasks frame names settleFetches, settleCrypto and runMediaActions.

Machine checks. None. An experiment on a copy of main, running niso-quickjs’s existing tests/dom_parsing.rs with nothing added: --no-default-features --features glue-form-data fails with ReferenceError: parseMimeType is not defined, and --features glue-xhr with ReferenceError: encodingFor is not defined. That test file is itself wrong for a non-default build: it needs DOMParser without a cfg(feature = "glue-dom-parsing"). tests/check.sh builds default features only, so no combination is ever built. niso-jvm turns the engines’ default features off and does not re-enable them, so libniso_jvm appears to ship without FormData, XHR, DOMParser, FileReader and the atob fallback (read from the manifests, not built).

arch-6c-glue. Modules become function install(ctx), composed after the core in a hand-written install order. bindings/js/CONTRACT.md (142 lines, version 1) lists the ctx exports (also bindings/js/glue/ctx.js:15-35), five rules, three guarantees and the hook table, and the cross-module writes become setWorkerOwnerSecure and taskSource(f). But:

  • the contract concedes that its main rule is a convention: “A module still reaches the core’s names through ctx only by convention: the composition puts every module inside the glue’s function, where the core’s names are in scope” (CONTRACT.md:138-142). A heuristic scan found today’s modules clean; nothing keeps them so;
  • rule 2, “An optional module … may be left out; no other module uses it”, is already broken: fetch-bodies destructures NisoFormData and reads ctx.parseMimeType, and xhr destructures decodeWith and encodingFor;
  • ctx is not frozen, so guarantee 2 (“the core does not change them afterwards”) does not stop a module overwriting a core name;
  • the only enforcement is onHook throwing on a duplicate name (ctx.js:11-14);
  • the install order is not derived from Cargo dependencies.

Where. 20 files in idl/ on main (3,230 lines) and niso-idl’s model.rs, emit.rs, js.rs, jvm.rs and module.rs. It generates the Rust types, the C shims, niso.h, the JS host functions in C, the JS classes, the JVM classes and their JNI natives, and native-module glue (JS, .d.ts, Java). Six build scripts each re-parse the whole set.

Contract. architecture.md 7.1-7.2 (design) and doc comments. No list of niso’s extended attributes exists anywhere, and inventory.md:192’s counts are stale (today [NisoInternal] has 145 uses, [NisoGlue] 97, [NisoReentrant] 21, [NisoHeld] 14, [NisoNative] 4, [NisoValue] 3, [NisoArray], [NisoThis] and [NisoBooleanIs] 2 each, [NisoMut] and [NisoIntegerArray] 1 each).

Owners. On main every member’s de facto owner is niso-core: every shim calls niso_core::api::<iface>::<member> (emit.rs:504-593). arch-6c-gen derives an owner crate from the file’s path (crates/<crate>/idl/, else niso-core), generates <crate>::bindings, and enforces that the core’s IDL names no other crate’s interface and that a crate uses another crate’s dictionaries only through a Cargo dependency (read with a hand-rolled line parser, not a TOML parser). Per-owner generation exists for the Rust facade only: the C shims, JS hosts, JS classes and JVM classes are still generated for everything in one place, and load_workspace composes every crate’s IDL whatever the build’s features.

Machine checks. niso-idl has no tests on main (no #[test], no tests/ directory). Probes against niso_idl::load gave the same results on main and arch-6c-gen except where noted:

Input Result
two interface A main: accepted, both emitted; 6c-gen: refused, but only because the two ids collide
two attributes x of different types accepted; one silently dropped from the header
an overload the second silently dropped
two dictionary D, two enum E, duplicate typedefs accepted; the first or the last wins
[NisoFeature=sqlite] ignored and generated unconditionally (designed in architecture.md 7.2, parsed nowhere)
[NisoGlu] (a typo) ignored; generated as a C-backed member
attribute x and operation getX two niso_a_get_x in the header, and a rustc error later in OUT_DIR
an unknown type name main: an error; 6c-gen’s load: accepted as a handle, checked only by load_workspace

[NisoGlue] members are dropped from the model, so nothing checks that the glue implements them; a missing one just does not exist in JS. jvm_subset drops what Java cannot express without reporting it (jvm.rs:585-622).

Branches. arch-6c-gen adds tests/owners.rs (10 tests), tests/ids.rs (4) and the temporary header golden; it adds no uniqueness or extended-attribute validation. electron-compat adds innerText and outerText to idl/html.webidl, which arch-6c-gen moves to crates/niso-core/idl, so whichever lands second must re-apply the change.

niso_app.h (crates/niso-app/include/niso_app.h, one function, niso_app_main). The header comment is the contract: call order, the loop, handle lifetime, linking and errors. Missing: threads, reentrancy, unwinding (“must not unwind” is unsaid, and neither host has a boundary around the call) and versioning. Android dlopens the app library (crates/niso-android/src/lib.rs:216) and trusts its signature through transmute, with no check that both sides saw the same niso.h. Android ignores the error rule: it never reads niso_dom_last_error. iced has no app path, though the examples say “any niso host can run it”.

Swift (hosts/apple). An app, not a library: no Package.swift or project. docs/hosts/apple.md documents behaviour, not API. Eleven -niso-* launch arguments are a test interface documented mostly in Swift comments. Checked only by tests/check.sh --ios and --mac.

Android. 33 Java_dev_niso_host_Native_* exports (crates/niso-android/src/lib.rs:158-515) and 33 external funs (hosts/android/niso/src/main/kotlin/dev/niso/host/Native.kt:6-85), bound by symbol name and matched today by eye; a mismatch fails at its first call with UnsatisfiedLinkError. The crate has no catch_unwind, so a panic aborts the app. The :niso Gradle library is used as an API by examples/ihatemyphone/android, with no stability statement and no Kotlin explicit-API mode, so every top-level declaration is public, the raw object Native included. Its intent extras (niso.taps, niso.notext, and so on) spell the Apple launch arguments differently, and tests/screens/compare.mjs re-encodes them per host.

JVM (dev.niso.dom). Generated from the IDL; the Javadoc states threading and handle ownership (Dom.java:10-16). The 34 runtime natives are written in Natives.java and in niso_jvm.c’s RegisterNatives table; a mismatch fails at class load, a reliable if late check.

iced. A program over niso-core internals (niso_core::controls, Blob, unsafe Dom::tap and friends), with no surfaces, no platform style and no app path. docs/linux.md:6-10 is stale: it still says the host does not report fieldStyle.

Surfaces. The header names widget.small, widget.medium, activity, island.leading, island.trailing and island.minimal (rendering.h:302-311). The hosts also use island.expanded (hosts/apple/Widgets/Widgets.swift:85, 97, in no doc) and carplay.list with the carplay. prefix (CarPlay.swift, documented only in docs/hosts/apple.md). Android gives island.trailing a meaning of its own (Surfaces.kt:117, 173), files a page’s carplay.list as a widget, and turns every enabled button into a notification action where the platform shows three. Nothing lists which names each host renders.

Platform CSS prelude and tokens. crates/niso-core/ua/niso.css defines 38 --niso-* tokens over --apple-system-* and --md-sys-* names, loaded by <meta name="niso-platform-style"> (crates/niso-core/src/prelude.rs). The families are documented (document.h:54-67, DECISIONS.md); the individual tokens and the names a host must set for each platform are not: the CSS file is the list. tvOS sets no background and no large title, watchOS no link, separator or fill, only iOS sets the grouped backgrounds, and Apple has no source for --niso-color-on-accent or the corners. auto is decided by cfg!(target_os), so every Apple platform but iOS gets the web sheet. Hosts publish --niso-safe-area-inset-* themselves (iced does not). Two core tests check the mechanism with invented values; nothing checks a host against the list.

niso attributes and meta. data-niso-surface; data-timer-start, -end and -period (ordinary data- names, so a page’s own use of them changes its templates); <meta name="niso-platform-style">. DECISIONS.md decides --niso-text-engine, -apple-glass-effect, @media (platform: ...) and (form-factor: ...); none of them is in the code, and no document lists niso’s extensions to HTML and CSS.

What every host must implement, assembled here because nothing lists it: the render ops and their version; the control kinds, sizes and styles; the platform style names; the surfaces it chooses to render; and the probe for the screens harness. No manifest, suite or cross-host test covers any of it. architecture.md 7.5’s trait suites cover what a host provides to niso, not how it consumes niso’s output, and that is where the hosts drift.

Page (bindings/js/napi/niso.js:159-467). Constructor options and a dozen methods documented by use, with unmarked internals on the same class (toPage, parseError, reportRejection, moduleOf and more); module.exports = { Page, addonPath }. package.json is {"private": true, "type": "commonjs"}: no name, main, exports, types or engines, and there is no .d.ts. docs/engines/napi.md explains the model but not the API. The addon is whichever is newest in target/, with no version check against niso.js. test.js (914 lines) tests behaviour under node, deno and bun; adding or removing a method breaks nothing.

Window (node-windows). class Window extends EventEmitter with options, events (resize, a cancelable close, closed, focus, blur) and methods, documented in docs/engines/node-windows.md there. There is no test of any kind: window.rs has no #[test] and test.js is unchanged. The branch also turns hosts/iced into a library (niso_iced) that a binding depends on, and the Electron design names this API as the surface its shim will wear.

Electron shim (electron-compat). electron.js, renderer.js, realm.js, host.js, a CLI and seven fixture tests wired into crates/niso-napi/tests/node.rs, including a real escape test. The claimed API level is a constant (electron: '45.0.0' in host.js), and the export list and its 34 “not implemented” stubs are typed by hand, so a name missing from the list is undefined rather than the promised error. The shim imports ../napi/niso.js by relative path and uses toPage and a report the branch adds, so the Node package’s internals are already a cross-package API. Its package.json has no name, though decision D10 ships it as @niso/electron.

CLIs. Each parser is a hand-rolled match, and all four turn an unknown flag into something else: niso-run into a script path (crates/niso-quickjs/src/bin/niso-run.rs:121), niso-iced into the script it runs (hosts/iced/src/main.rs:135), niso-node into a script (bindings/js/napi/niso-node.js:133) and niso-wpt into a directory (crates/niso-wpt/src/main.rs:141). Docs drift: niso-node says “the options are niso-run’s” but lacks --serve, --missing and --hold; napi.md omits --storage and --wpt; niso-iced’s printed usage omits 8 of its 14 flags; niso-wpt’s --engine quickjs is undocumented. On node-windows, niso-node --window rejects unknown options and gives --html a different meaning from the main path. No test runs any CLI with arguments. The shell scripts (tests/check.sh, hosts/*/build.sh, compare.mjs) are the best-kept CLIs: each documents itself, and most reject unknown options.

probe.json and harness payloads. Four writers: Swift by string interpolation (Engine.swift:258-275), Kotlin by concatenation (MainActivity.kt:74-97, where fieldStyle and buttonStyle are optional), iced by json! (with no platformStyle), and niso-run --boxes, which re-implements probe.js’s element paths in Rust without its radio-group exclusion. The reader, compare.mjs, reads fieldStyle unconditionally (:265). No schema, no validation and no writer test; iced’s missing fieldStyle once stopped the whole --iced run. Swift decodes the base64 JSON harness payloads with (try? ...) ?? [] (App.swift:146, 158, 167), so a bad payload silently gives a reference screenshot with no controls.

Expectation files. tests/wpt/.json and tests/test262/.json are described in docs/wpt.md:81-118 and read as untyped JSON with unwrap() (crates/niso-wpt/src/main.rs:446-448). A malformed file panics; a missing one is an empty map, so a renamed expectations file silently means “expect nothing”. The merge driver has tests (tests/wpt/merge.test.mjs); the files’ shape has none. tests/screens/expectations.json and tests/acid/expectations.json fail on stale entries, which is good, and have no schema.

  • Persisted formats (12). IndexedDB data outlives an app update. The structured-clone bytes carry VERSION and other versions are rejected (that crate’s guarantee 2). The key encoding has a property test but no stored golden. The SQLite schema (crates/niso-storage/src/sqlite.rs:34-49) is created with CREATE TABLE IF NOT EXISTS and has no schema version (meta.version is the IndexedDB database’s version), so a schema change will have nothing to migrate from.
  • niso-intl’s C ABI (13). crates/niso-intl/include/niso_intl.h: one call, its own free function, and status codes 0-4 outside niso_status. No contract; checked only through test262’s intl402.
  • Other byte formats (14). niso_net_take’s response list (network.h:36-47), module value lists (modules.h:6-12), niso_media_session_take_actions, niso_wasm_module_custom_sections, the surfaces list, niso-run’s --render file and the JSON niso-wpt sends niso-node --wpt on stdin. Prose or code only, none versioned.
  • Native modules (15). docs/modules.md (100 lines) and modules.h are a fair contract, and the quickjs and core tests and the Java example cover them. The JS, .d.ts and Java generated from an app’s [NisoModule] IDL have no snapshot.
  • Build-time composition (16). niso-compose’s contract and unit tests cover sections and order, and cargo xtask layers covers dependency edges. Their gaps are above: rule 6 checked by file presence, compositions unchecked, the install order written by hand, the glue features declared three times.
  • Environment variables (17). About twenty NISO_* variables, each documented in the header of the script or binary that reads it, with no index.

3. The rule: one written contract and one machine check per boundary

Section titled “3. The rule: one written contract and one machine check per boundary”

Every boundary in section 0’s table gets:

  1. One contract, in one place. A crate’s boundaries go in its CONTRACT.md: its Rust API, its C sections, its IDL, its glue module and its byte formats. Boundaries that are no single crate’s get their own: bindings/js/CONTRACT.md (the glue core’s ctx and the engine interface, niso_engine.h and niso_js.h), crates/niso-capi/CONTRACT.md (the ABI-wide frame rules: threads, ownership, errors and last_error, NULL, reentrancy, versioning), hosts/CONTRACT.md (what every host must implement: render ops, controls, the platform style names per platform, surfaces, the probe), bindings/js/napi/CONTRACT.md (the Node package) and tests/CONTRACT.md (the harness formats and CLIs). A contract may point at a spec file (rendering.h, docs/structured-clone.md) instead of repeating it, as long as the spec is the only copy.
  2. Two new template sections. “Errors”: every error a caller can see, and the boundary’s state after one. “Versioning and stability”: what counts as breaking, which items are stable, experimental or test-only, and the bump rule (an addition bumps the minor number, anything else the major; the number is the version: line and, for C, a macro).
  3. At least one machine check, the cheapest that fails when the boundary changes or an implementation breaks it: generation from one source, a committed snapshot that a change must update explicitly, or a contract suite that every implementation in the repository runs. A boundary with several implementations (hosts, engines, readers, drivers) needs a suite or a fixture, not only a snapshot.
  4. A registry the lint reads. xtask/boundaries.toml lists each boundary with its contract file and its check (a test name or an xtask subcommand). cargo xtask contracts fails when a workspace crate, a bindings/ directory or a hosts/ directory has no contract; when a contract lacks a template section or a version line; when a registry entry names a missing file or test; and when a snapshot changed without the owning CONTRACT.md changing in the same diff. Exceptions go in an allowlist with a reason and a removal step, and the list only shrinks, as the layers allowlist does.
  5. One gate that runs all of it. cargo xtask gate runs the fast checks (layers, contracts, snapshots, goldens, the IDL and glue lints, the conformance fixtures) in a few minutes on Linux without devices, and is required before a merge. CI runs it when CI exists; tests/check.sh calls it first.

Sizes: S is up to a day for one agent, M two to four days, L one to two weeks. The order follows the waves. Wave 0 owns niso-host, niso-sched, niso-idl, niso-compose and xtask (migration.md: “No wave agent edits niso-host, niso-sched, niso-idl or niso-compose”), so every check that touches them, and every contract gap a wave agent would otherwise have to close, lands in wave 0.

  1. cargo xtask contracts and cargo xtask gate (S). The registry and lint of section 3, starting with an allowlist of today’s missing contracts (niso-core, niso-capi, niso-idl, niso-app, niso-android, niso-napi, niso-jvm, niso-quickjs, niso-porffor, niso-crypto, niso-actions, niso-intl, bindings/js, bindings/jvm, hosts/*) and the template’s two new sections. From then on a crate cannot land without a contract, which enforces migration rule 5 for waves 1 to 3.
  2. Close the contract gaps that block wave 1 (M, wave 0 agents). Registrar gets a route for niso-sched Sources (2.5), decided and tested before step 7. niso-host’s and niso-xpath’s contracts state the versioning rule and take the bump it implies for arch-host-prep’s and arch-6c-gen’s changes. arch-6c-glue’s bindings/js/CONTRACT.md either allows a module to use an optional module through ctx lazily, and says so, or moves parseMimeType, decodeWith and encodingFor into required modules.
  3. Permanent header goldens and an ABI version (S-M, with 6c-gen). Keep arch-6c-gen’s header test after the merge, unsorted, for both niso.h and niso_runtime.h, as crates/niso-capi/tests/golden/, with a cargo xtask bless to update them. Add #define NISO_ABI_VERSION and uint32_t niso_abi_version(void), bumped by section 3’s rule. Add a test that the composed niso_runtime.h prototypes match niso-capi’s exports by name, arity and scalar types (the one-off check that found 127 of 127 matching today, made permanent).
  4. cargo public-api snapshots (M). crates//public-api.txt for every library crate, made with a pinned nightly (so that auto traits such as core::marker::Forget do not churn every snapshot) and -s (blanket impls off, auto traits kept, since Send and Sync are real API), for default features and for contract-tests. The gate fails when a snapshot differs from the committed one, and cargo xtask contracts fails when a snapshot changed and the crate’s CONTRACT.md did not. niso-core gets a snapshot too, as a diff gate rather than a document (4,238 lines, about 6,200 after 6c-gen). #[doc(hidden)] items that hosts must call (AlgorithmId::new and get) are either made public API or covered by a test that calls them, since rustdoc omits them. niso-quickjs needs its vendored QuickJS sources present, or a build.rs that skips the C build under rustdoc.
  5. IDL validation in niso-idl (M, with 6c-gen). Uniqueness of interfaces, members, C names, dictionaries, enums and typedefs; an allow-list of extended attributes, documented as a table in niso-idl’s new CONTRACT.md; [NisoFeature] implemented or refused; and a snapshot of one small fixture IDL’s generated C, Rust, JS and Java.
  6. Glue checks (M, with 6c-glue). One test per engine that evaluates the composed glue and asserts it returns every hook name niso_js.c and engine.rs call. A ctx lint that parses each module (the heuristic scan used for this audit is a starting point) and fails on a free name that is neither in its const {...} = ctx nor a global. A feature matrix in the gate that builds and runs niso-quickjs’s tests with each optional glue section off and with all of them off. And Object.freeze on the core’s part of ctx, or a check that modules only add new names.
  7. Implementor-side 6d suites (S). Change document_hooks_suite, resource_loader_suite and worker_host_suite to take a constructor for the implementation under test, as storage’s and wake’s do, keeping the recording implementation as one caller, and run the DocumentHooks suite against niso_js.c’s C hooks from a niso-quickjs test. Step 19 then has suites to run against each rewritten binding.
  1. Every wave crate lands with its four artefacts (no new work, now enforced): a CONTRACT.md in the extended template, a public-api snapshot, a contract suite run on FakeHost and on the document, and source_suite on its source. Step 7 (websocket) is the pathfinder and should be reviewed against this list.
  2. Render stream conformance fixtures (M). A directory of recorded streams, each with the tree it must produce as JSON, covering every op and field of the current version. Every reader decodes them in a unit test that runs on Linux: niso-core’s test reader, boxes.rs, the iced host, the Kotlin reader as a plain JVM unit test, and the Swift reader with swift test where a Swift toolchain exists (the decoder needs no UI). Op numbers and field layouts move into one table in niso-core, from which rendering.h’s constants and the hosts’ constants are generated or checked. The three hosts call niso_render_version() and refuse a mismatch.
  3. Template fixtures and a surface registry (S). The same fixture approach for templates, including an old decoder reading a newer template; the encoder checks its u16 and u8 limits; and surface names get one list in rendering.h, with each host declaring which it renders.
  1. Render stream version 3 (M). An in-band header (magic, version, frame sequence number) and a u32 length per op, so readers skip unknown ops and detect a dropped or reordered frame. Op numbers are allocated in item 9’s table, with a range per contributing crate, before L2 prep adds RenderContributor. Needed before electron-compat’s out-of-process rendering.
  2. The C ABI’s frame rules, written and tested (M). niso-capi’s CONTRACT.md decides whether a successful call clears last_error and makes ffi::finish match; every hand-written function NULL-checks dom and runs under ffi::query or the bracket; NULL out parameters are allowed or refused, consistently; generated shims build owning callback structs before any fallible conversion, so drop-on-error is one rule; and the contract has the table of free-threaded functions. The C smoke test grows into the C-level conformance suite DECISIONS.md describes, one test per frame rule.
  3. Host contract and platform-token check (M). hosts/CONTRACT.md with the per-platform list of names a host must set; a test that every var(--...) in ua/niso.css and the base sheets is defined there or on that list; and a test that reads the Kotlin and Swift appearance code for the list (both are string tables, so this runs on Linux). The same for the control style structs.
  4. JNI and Node package checks (S each). A test comparing Native.kt’s external funs with niso-android’s exports by name and arity, or a switch to RegisterNatives as the JVM binding does, and catch_unwind at every JNI entry. For Node: a name, exports and a hand-written .d.ts in package.json, checked with tsc --noEmit against test.js, and an API snapshot test of Page’s and Window’s own property names, before node-windows and electron-compat merge.
  5. Engine conformance suite (L). One fast JS test set that every engine runs (QuickJS in process, Node-API under node, Porffor with a fixed recorded script set), covering the engine interface: hooks, wrappers and identity, finalization, reentrancy, errors and capabilities. It must exist before step 19 rewrites the bindings onto rquickjs and the facades, since it is that step’s regression net, and it is the “conformance smoke test that runs in seconds” DECISIONS.md already promises.
  6. Tooling formats (S each). One small argument parser shared by niso-run, niso-iced and niso-wpt that rejects unknown flags and prints usage from its own table, with each binary’s module doc checked against that table (niso-node gets the same table in JS); a JSON Schema for probe.json, validated by compare.mjs on read and by an iced writer test; typed (serde) structs for the expectation files, with a missing file an error unless the suite has none; and Swift’s harness payload decoding failing loudly.
  7. Persisted formats (S, before step 9 moves IndexedDB). SQLite PRAGMA user_version for the schema and a committed database file from the current version that the current driver must open, and a committed corpus of encoded keys and structured-clone values that must decode.
  1. Per-composition goldens (S). Each composition crate (niso-capi, niso-app, niso-napi, the native-app compositions) commits its composed header with the NISO_HAS_<NAME> macros architecture.md 6.2 describes, and its composed glue’s module list, so a dependency change shows as a reviewed diff.

After items 1-7 (wave 0), every crate’s Rust surface, both headers, the IDL generator, the glue’s module boundary and the host traits are checked, and wave agents work against contracts that say what they may change. After 8-10 (wave 1), the render stream and templates have fixtures every reader passes. Items 11-17 close the consumer side (hosts, engines, Node, tooling) before steps 18 and 19 rewrite it. The order matters most for items 2, 3, 5 and 6: they touch niso-host, niso-idl, niso-compose and the glue core, which only wave 0 may edit.

Run on 621af1a6 with cargo-public-api 0.52.0 and nightly 1.102.0 (2026-11-09), as cargo public-api -p <crate> -s with default features, in a separate worktree with four build jobs.

  • Cost. Seconds per crate once dependencies are built: niso-host 1 s, niso-sched 2 s, niso-xpath 3 s, and niso-core 3 s after niso-actions had built its dependency graph (about two minutes); niso-storage 19 s and niso-crypto 55 s for their bundled C. The whole workspace except niso-quickjs took under six minutes cold.
  • Blocker. niso-quickjs’s build.rs compiles third_party/quickjs, which a fresh worktree lacks. rustdoc JSON works on every other crate, niso-core included (with 17 rustdoc warnings, such as public docs linking to private items).
  • Diffs against the branches. niso-host on arch-host-prep: +171 lines, 0 removed, with AlgorithmId::new and get invisible because they are hidden. niso-host on arch-6c-gen: 2 changed (capi::call and nested generic over their result) and 19 added, matching that branch’s contract diff. niso-xpath on arch-6c-gen: 19 added (the bindings facade). niso-core on arch-6c-gen: +1,940/-189 (the facade, ffi::enter, ffi::shim, and InterfaceId from an enum to a newtype), with no contract to change beside it.
  • What a real check must handle. A pinned nightly (auto traits such as core::marker::Forget and UnsafeUnpin appear on every type, and a new nightly would churn every snapshot); two feature sets (contract-tests adds up to 247 lines, and the contracts promise that module to outside implementers); hidden items; the QuickJS sources; and the size of niso-core’s snapshot, which makes it a diff gate rather than a document.