Skip to content

niso migration: from one core to composed crates

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

Status: proposal, to go with architecture.md and inventory.md. Code references are at origin/main 3f373ff. Branch facts were taken from git diff --stat against each branch’s merge base on 2026-11-09. Progress on 2026-11-10: steps 1-5, 5a, 6a, 6b (the cut-over, 1afef90) and 6d (621af1a6) are on main; 6c-gen and 6c-glue are on arch-6c-gen and arch-6c-glue.

  1. One step is one PR by one agent, reviewable as one crate’s diff plus at most one contract. No step changes behaviour; the composed build’s tests (niso-quickjs, niso-capi-tests, WPT expectations) stay green at every step and are the proof.
  2. Move first, then improve. A step that moves code into a crate changes paths and visibility, and nothing else. niso-core keeps a pub use re-export for every moved item (pub use niso_structured_clone as structured_clone;), so no caller changes in the same PR. Later steps delete the re-exports when nothing uses them.
  3. The extension path exists before anything uses it. Step 3 adds ObjectData::Ext(Box<dyn PlatformObject>) and a slot table to Dom, so subsystems can leave the 52-variant enum and the 90-field struct one at a time. Each extraction deletes its own variants, fields and match arms, and so leaves those central lists shorter.
  4. The layering lint starts with an allowlist of today’s edges and is ratcheted. A step may remove edges from the allowlist and never add one. The one exception is a new web API crate whose trait the document still has to implement: it may arrive with an entry for that edge, provided the entry names its reason and the step or condition that removes it (architecture.md 2.1). niso-xml did, taking the list from 9 entries to 10 with niso-core -> niso-xml, which step 8f removes.
  5. Each new crate arrives with CONTRACT.md, its testing fakes and its contract suite (architecture.md section 8), and with cargo test -p <crate> green without niso-core in its dependency graph.
  6. Timing. Start after storage-fixes merges (it is next, 17 commits ahead and 3 behind main, and rewrites storage/ and event_loop.rs). Steps 1-3 do not touch the files sched-followups, engine-napi or tv-input edit heavily, so they can land while those branches are open.

Creates crates/niso-base (no niso dependencies; no third-party ones).

  • Moves: ErrorCode, Error and Result (lib.rs:74-131); Obj and its top-bit and generation helpers; the C value types and conversions from ffi.rs (NisoStr and NisoBytes reading, the Callback triple of function, userdata and destructor).
  • Leaves behind: impl From<DriverError> for Error (storage/mod.rs:80) stays in niso-core until step 5 moves the driver. InterfaceId stays the generated enum in niso-core for now; hashed ids come with niso-compose (step 6), because they change the values niso_object_interface returns (architecture open question 5).
  • APIs introduced: none new; the moved items become niso_base::*, re-exported from niso-core under their old names.
  • Contract: the numeric values of ErrorCode are ABI. Its contract test asserts each value against the NISO_* constants that rt.h defines, so a renumbering fails without a host.
  • Done when: cargo test -p niso-base passes, and the workspace tests are unchanged.
  • Conflicts: plat-blob-abort-crypto adds one ErrorCode (lib.rs hunk at 108). Whoever lands second adds it to niso-base instead, which is a one-line resolution. No other open branch touches lib.rs:74-131.

Creates crates/niso-structured-clone, which depends on niso-base.

  • Moves: structured_clone.rs (804 lines, no reference to Dom) and its 8 unit tests. The hand-written niso_structured_clone_* C shims in capi:src/clone.rs stay in niso-capi and only change their import path; they move into the crate’s header fragment when step 6 exists. glue.js’s SC section (239-880) stays where it is until glue modules exist (step 6).
  • APIs introduced: testing::values(), a corpus of encoded graphs (cycles, Map and Set, typed arrays and views, transferred buffers, strings that are not well-formed UTF-16). storage’s key tests and messaging’s tests can then use one shared corpus.
  • Contract: the byte format, read and write, is the contract. Guarantees: round-trip for every corpus value, rejection of truncated input with DataCloneError rather than a panic, and js_string agreeing with the JS engine’s string order. These are checked by property tests over the corpus.
  • Done when: cargo test -p niso-structured-clone passes in a few seconds (architecture.md 9.3), with no niso-core in its tree. storage/key.rs and indexeddb keep crate::structured_clone::js_string through the re-export.
  • Conflicts: plat-blob-abort-crypto changes structured_clone.rs by 10 lines (Blob serialization). That is a small hand-port into the moved file in either order. No other branch touches the file.

Step 3: niso-host, with the extension path in niso-core

Section titled “Step 3: niso-host, with the extension path in niso-core”

Creates crates/niso-host (depends on niso-base), and makes niso-core its first host.

  • Moves: Waker from modules.rs:43-79 (net.rs, messaging.rs and modules.rs already share it), unchanged in behaviour.
  • APIs introduced: Cx, HostServices, ExtKey<T> and HostState (slots, deferred frees, the borrow record, hooks), PlatformObject with held, freed and heap_size, EventInit and Dispatched, TaskSource, Inbox<T>, Registrar (interfaces, slots and hooks; sources come in step 4), and testing::FakeHost. Exact shapes are in architecture.md section 3.2.
  • Changes to niso-core, the minimum needed to host:
    • Dom gains one field, host: Box<HostState>.
    • ObjectData gains one variant, Ext(Box<dyn PlatformObject>). interface_of (lib.rs:737-827) and free_object (lib.rs:876-951) each gain one arm that delegates to the trait; collect (lib.rs:1056-1085) needs none, since crates’ objects are reference counted only.
    • impl HostServices for Dom forwards to what exists: now, event_loop timers, Task with a new Task::Ext(Box<dyn FnOnce(Cx) -> Result<()>>) variant for queue_task, and dispatch through Dom::dispatch (events.rs:343) with explicit parents for object targets, which IndexedDB’s idb_parent walk (events.rs ~296-312) does today.
    • niso_host::capi::call wraps ffi::call (ffi.rs:194) to hand a Cx to shims.
  • Contract: niso_host::contract::host_suite covers object identity and generations, release order, freed running at a safe point, fire returning canceled and listener_threw, and the rule that no slot borrow is held across script. The suite runs against both FakeHost and Dom, and that is the step’s main deliverable: it is what lets every later crate test on FakeHost alone.
  • Done when: both hosts pass host_suite and nothing uses the new path yet. A throwaway test object registered through Registrar proves the C API, interface and free paths work end to end, and is removed in step 7.
  • Conflicts:
    • plat-blob-abort-crypto and tv-input both add ObjectData variants and Dom fields (lib.rs hunks at 176, 429, 446 and 200, 350). Those are textual neighbours only and resolve by keeping both sides.
    • sched-followups changes modules.rs by 35 lines (the module budget, not the Waker). If it has not merged, the Waker move waits and step 3 re-exports it from niso-core instead.

Each later step is named by what it moves; each follows the same rules. Where a step waits on a branch, the branch is named.

# Step Waits on Notes
4 niso-sched: Source, SourceInfo, the scheduler (event_loop.rs, run_tasks lib.rs:1395-1449), SimpleHost. The loop’s subsystem clauses (run_ready event_loop.rs:175-185, the fetch branch 299-308, module budget 330-336, wait_network) become core-internal sources registered at Dom::new storage-fixes and sched-followups merged (both rewrite event_loop.rs; sched-followups also rewrites websocket.rs by 196 lines and wait_network with it) The riskiest step. Lands with contract::agreement (random interleavings, asserting the rt.h iff) passing before and after. The perf harness records needs_frame cost before and after.
5 niso-storage: storage/*.rs, Config; exercise (storage/mod.rs:408) becomes contract::suite; the C driver test runs the suite through the vtable storage-fixes merged Moves the From<DriverError> impl. Configuration lives in a slot.
5a Read-only DOM traversal: niso-host’s Tree (kinds, names, structure, attributes, data, document order, node documents, isConnected), lent through Cx::tree; the document implements it (DomTree, which numbers sibling positions once per lending), FakeHost fakes it, and contract::tree_suite runs on both. niso-xpath moves onto it and takes the XPath interfaces from api/xpath.rs 5 Removes the niso-core -> niso-xpath allowlist entry. XPath’s 200,000-node benchmark is measured before and after; walks stay linear and iterative. niso-capi’s XPath shims call niso-xpath’s api until step 6 generates them per crate, and niso_dom_new installs it.
6 niso-compose and per-crate fragments, in four parts. 6a, merge-safe: niso-compose and its manifest, crates/niso-compose/sections.txt, which gives each section of niso_runtime.h and glue.js an owner crate, cut at anchors (the starts of named lines) rather than line numbers. The builds compose both files from the sections: niso-capi’s build.rs the header, grouped by owner in dependency order; niso-quickjs’s, niso-porffor’s and niso-napi’s the glue, in the monolith’s order until its sections are modules. cargo xtask split writes the sections to target/fragments and checks them. The monoliths stay the source, so open branches keep merging into them. 6b, the cut-over: one commit moves each section into its owner’s tree and deletes the monoliths (below). 6c, as two agents (6c-gen, then 6c-glue; see “Wave 0” below): niso-idl emits per fragment with an owner crate (emit.rs:504-593), and the C shims and each crate’s Rust facade enter through one bracket in niso-host (architecture.md 7.1); glue sections become modules with install(ctx); hook names replace enum qe_hook; hashed interface ids. 6d, an agent beside 6c-gen: the host-provided interfaces become Rust traits with C adapters (architecture.md 7.5): ResourceLoader for the loader, Wake for the wake hook, DocumentHooks for the tree, task, script, microtask, message and handler hooks, and WorkerHost for the worker runner and host, each with a contract suite run against its reference implementation and against the C adapter over it, as niso-storage’s is; the C setters keep their signatures and install an adapter open question 5 decided Done when the composed niso.h and glue.js have today’s content modulo order: 6a checks it on every build and in cargo xtask split, and 6b’s cut-over is checked against the last monolith. After 6c, a crate brings its own IDL. 6a merged as 2c82102, 6b as 1afef90 (the cut-over below) and 6d as 621af1a6.
0a Source registration: Source and SourceInfo move from niso-sched into niso-host, and niso-sched re-exports them. Registrar::source records sources in registration order, and the document’s scheduler and SimpleHost register the recorded sources at install. niso-sched gains testing::FakeLoop, and a source_suite case runs on FakeLoop, SimpleHost and the document host prep (WorkerSeed, for the worker case) Wave 0, boundaries.md item 2’s route for sources. Two guarantees, each with a test: a source a crate registers keeps the exact loop position its core-registered version has today, and the contract says how a registered source’s rank relative to the core’s own sources is decided; workers that replay the seed’s register functions get the same sources in the same order. Every step whose crate brings a Source waits on it.
0b time_origin: HostServices::time_origin, one commit by the lead 3 Wave 0, before 8c.
0c RenderingStep #[non_exhaustive], with a constructor, so a field added later (phases, for example) does not break the crates that build one host prep Wave 0, before wave 2, whose step 16 is the first crate to register one.
7 niso-websocket: websocket.rs (786 lines), websockets.webidl and its header and glue sections. The WebSocket object; CloseEvent’s payload; message events with niso-host’s MessageEvent payload; the connection Source with live 6c-gen, host prep, source registration (0a) Wave 1’s pathfinder: its review sets the template the other crates copy. Deletes ObjectData::WebSocket, web_sockets, the Close payload in EventExtra, and wait_network.
8a niso-url: api/url.rs (184) and api/url_search_params.rs (95), url.webidl. URL and URLSearchParams objects; pure 6c-gen Small; calibrates the per-crate review flow.
8b niso-encoding: api/encoding.rs (102), encoding.webidl. TextEncoder and TextDecoder; pure 6c-gen
8c niso-performance: api/timing.rs (219), performance.webidl, the performance glue section. Marks and measures, the time origin from Cx::now and HostServices::time_origin 6c-gen; time_origin (0b)
8d niso-geometry: the glue core’s geometry.js (DOMPoint, DOMRect, DOMQuad, DOMMatrix and their read-only parents) as a crate’s glue module, with DOMMatrix’s string parsing as one Rust function over stylo’s transform parser (stylo directly, not through the core) 6c-glue Glue-only apart from that function. niso-core’s geometry.rs is layout (border boxes) and stays.
8e atob, btoa and base64 into niso-encoding: the glue core’s atob.js and base64.js. The base64 helpers that fetch’s data: URLs and file-api use are exported through ctx 8b
8f DOMParser and XMLSerializer into niso-xml: api/dom_parsing.rs (202) and dom-parsing.webidl. Serialization reads the tree through Cx::tree (5a); parsing builds a new document through host prep 2’s document factory host prep 2 Removes the niso-core -> niso-xml edge. innerHTML and outerHTML (serialize.rs) stay in the core.
9 niso-indexeddb: indexeddb/* (2,051) and api/indexeddb.rs (885), the indexeddb glue section (548). Its 19 ObjectData uses; the idb_parent walk as explicit event parents; the transaction Source; idb_cleanup as a CheckpointEnd hook; WorkerSeed 7, 10, host prep, source registration (0a) 7 sets the template, and 10 edits storage/mod.rs too. Includes the standalone example (architecture.md 11.1).
10 niso-webstorage: api/storage.rs (213), the areas in DomStorage, Storage, StorageEvent and the localStorage partial out of html.webidl. StorageEvent’s payload; events to the other documents through HostServices::agent host prep
11a niso-blob: Blob, File, FileReader and the blob URL store (the file-api, file-reader and blob-urls glue sections, net.rs’s blob store) 6c-gen Open question 4. FileReader is optional glue.
11b niso-fetch: net.rs (756) and net/fetch.rs (1,089), the network header section, the fetch, form-data, fetch-bodies and xhr glue sections. ResourceTransport, which 6d’s ResourceLoader joins; glue.js’s settleFetches in tasks() becomes a Rust Source; the sync fetch and upload progress hooks; AbortSignal through Cx 11a, host prep, source registration (0a), host prep 2 (creating, combining and aborting signals, for Request.signal) XMLHttpRequest and FormData are optional glue (open question 12).
12 niso-crypto: api/crypto.rs (407) into the existing crate, the crypto glue section (1,024). CryptoKey objects; a job Source with live 6c-gen, source registration (0a) Removes the niso-core -> niso-crypto edge.
13 niso-messaging: messaging.rs (865), worker.rs (718) and api/messaging.rs (426), the workers header and messaging glue sections. Ports, channels and Worker; the statics at messaging.rs:43-381 stay process-wide inside the crate; 6d’s WorkerHost moves here; WorkerSeed 7, host prep, source registration (0a) Worker IndexedDB (open question 2) is decided in step 9’s seed.
13a niso-navigation: api/history.rs (172) and api/location.rs (168). History and Location; PopStateEvent and HashChangeEvent with typed payloads; fragment navigation from anchors through a core hook. The document’s URL stays in the core and is reached through Cx host prep 2 Deletes EventExtra::PopState and HashChange and ObjectData::Location and History. With 9, 10 and 13 it is the last user of niso-structured-clone in the core, so the edge goes with whichever lands last.
14 niso-wasm: wasm.rs (950), capi wasm.rs, the wasm header and glue sections (864), niso_js.c’s host functions as js/hosts.c. The store in a slot; the WasmHost trait (call, task); a task Source 6c-glue, 6d, source registration (0a) niso-napi’s composition leaves it out.
15 niso-modules: modules.rs (291), the modules header and glue sections. The NativeModule trait, whose C adapter is niso_module_*; an inbox Source and its waker 6c-gen, 6d, source registration (0a)
16 niso-animations: animation.rs (1,321) and api/animations.rs (480), web-animations.webidl, the animations glue section. Animation, KeyframeEffect and the timelines; animation_style becomes the core’s StyleApplier; a RenderingStep host prep, source registration (0a), RenderingStep (0c) The mutation_microtask_signal write at animation.rs:863 becomes a CallBracket hook.
17 niso-gamepad tv-input merged Parked with tv-input (architecture open question 8).
consumers-1 Per-consumer render frames (docs/design/accessibility.md on a11y-design, 2e6042bb, section 5.1, decisions M1-M4 and M6): niso_render_attach and niso_render_detach; one snapshot built once per frame, only when a generation moved, with a revision per node for each op’s payload and a log of destroyed nodes; each consumer pulls the changes since the frame it last took, into a buffer that stays valid until its next pull; every frame carries the document’s frame number; scroll feedback and a refused edit’s resend go per consumer; one consumer owns the viewport and the pointer. niso_render_frame stays as consumer 0, so existing hosts don’t change. It changes rendering.h and the C setters in ffi.rs boundaries.md items 9 and 11 (render stream v3); the user’s decisions D1-D13 Wave 3, by the architecture lead. Not to start until the user has decided D1-D13 of accessibility.md. It lands on render stream v3, not beside it: v3’s in-band frame sequence number is M4’s frame number, one render version covers both changes, and item 9’s fixtures then cover the per-consumer frame for every reader. It lands before L2 prep, so RenderContributor writes into the revisioned snapshot from the start instead of being retrofitted. It adds C functions, so it bumps NISO_ABI_VERSION and updates the header goldens (boundaries.md item 3). Consumer 0 must see the same ops as today (rule 1). An unchanged render_frame takes 38 ms on a 10,021-node page today; with generation gating it is a check, and the step reports both numbers.
L2 prep niso-core’s ElementInterface and RenderContributor hooks, which the L2 crates plug into 6c-gen Runs beside wave 3.
18a niso-media (L2): media.rs (235), HTMLMediaElement out of html.webidl, the media header and glue sections. The MediaPlayer trait; the media session L2 prep Deletes the media parameter of render.frame (lib.rs:1305-1309).
18b the canvas element (L2): api/canvas.rs (1,470) and canvas.rs (109), the canvas glue section. An element interface and a render contributor over niso-canvas L2 prep The canvas element leaves niso-core’s canvas feature for an L2 crate; niso-canvas stays a web API crate. Removes the niso-core -> niso-canvas edge.
18c niso-surfaces (L2): template.rs (398) and the surfaces part of render. A render contributor L2 prep
18d niso-forms (L2), as three stacked steps that each land. 18d-1: the text control selection APIs (api/text_selection.rs, 333, and the glue core’s selection.js). 18d-2: form submission (submit, requestSubmit, the submit event, implicit submission from input.rs) and the form’s entry list, which niso-fetch’s FormData then uses. 18d-3: the controls (controls.rs, 874): their value rules, the native-edit InputEvents (events.rs 758-840) and the render stream’s control state, as an ElementInterface and a RenderContributor L2 prep; 18d-2 after 11b Form owners, labels, document.forms and select options are tree-bound and stay in the core. Range’s live updates stay in the core.
18e niso-uievents: input.rs’s event synthesis (pointer events, compatibility mouse events, click, pointer capture, wheel and keys) and the UIEvent, MouseEvent, PointerEvent, WheelEvent, KeyboardEvent and InputEvent payloads. Hit testing and focus stay in the core and are reached through Cx host prep 2; tv-input decided Deletes EventExtra::Pointer, Wheel, Key and Input. tv-input’s focus and spatial navigation overlap it, so it waits on that branch’s decision.
a11y-1 The accessibility read view in niso-core (accessibility.md 9): one named read view rather than scattered pub items, over computed style (display, visibility, generated content, inert, overflow, direction), boxes relative to their render paint parent, form state, focus and the top layer, ElementInternals’ default ARIA and a node’s native peer kind, and change generations (mutation, style, layout, focus, form, selection, scroll). Touches render.rs, geometry.rs, the controls and api/aria.rs; not ffi.rs, host.rs, loading.rs or the hooks L2 prep; the user’s decisions D1-D13 Wave 3, after L2 prep. Small. Not to start until the user has decided D1-D13 of accessibility.md. Per D11 the view is a niso-core API that the L2 crate reads, not a niso-host trait; it becomes one only if a second document needs niso-a11y. The frame number it reports is the one consumers-1 puts in the render stream.
18f niso-a11y (L2, a new crate; a11y-2 in accessibility.md): the accessibility tree computed from a11y-1’s read view, kept in AccessKit’s model and diffed into TreeUpdates; the niso-owned C encoding (niso_a11y_frame), pulled per consumer; actions through niso_a11y_action with the input bracket; a11y.webidl, CONTRACT.md, testing fakes, the role tables, the headless renderer (niso-a11y-dump) and the snapshot harness, with its contract suite run on the document. Then the bridges in parallel: a11y-3 iced and desktop (AccessKit’s adapters), a11y-4 Apple, a11y-5 Android; and a11y-6 text runs and selection, a11y-7 phase-2 text anchors a11y-1; consumers-1 (the per-consumer pull); D1-D13 Wave 4, beside 18d and 18e; it can start on a11y-1’s branch. a11y-6 waits on selection-range and the text driver.
19 Bindings move off niso-core internals onto public APIs (rule 6): niso_js.c and the QuickJS adapter (qe_quickjs.c) are rewritten in Rust on rquickjs over the crates’ generated Rust facades; niso-napi calls the facades through Node-API from Rust, and niso-jvm through JNI; Porffor keeps a thin C adapter over the C ABI, since its generated code exposes only C. Every entry, from any language, goes through 6c’s bracket. In the other direction each binding implements 6d’s traits in its own interop (the rquickjs binding implements DocumentHooks in Rust, the JVM binding through JNI, Swift and Kotlin through UniFFI-style callback interfaces), and each binding’s tests run the traits’ contract suites against its implementations. niso-idl also generates the UniFFI layer (#[uniffi::export] on the generated facades, architecture.md 7.6) for Swift, Kotlin, Python and Ruby; the render stream and per-frame queries stay on the C ABI 6c, 6d; the QuickJS part after 14, which moves niso_js.c’s host functions Wave 4, as four agents: rquickjs, napi, jvm, and Porffor with the UniFFI layer. The binding edges to niso-core leave the allowlist. The glue stays JS; only the engine side changes.
20 Compositions: niso-app per product, niso-napi, and native app compositions (timer, radio, ihatemyphone), plus size budgets from cargo xtask metrics (planned; it does not exist yet) all above Re-exports in niso-core are deleted; the layering allowlist is empty.

Steps 7-18 run in waves, one agent per crate (below): each touches one crate plus deletions in niso-core’s central lists, and those deletions conflict only textually (neighbouring match arms), which is cheap because each is a removal.

One commit, on arch-6b-cutover from main 2c82102, once 6a, native-look, intl, xhr, key-defaults and the read-only traversal step had merged:

  1. cargo xtask split passed on main: the glue composed byte for byte and the header to the same lines.
  2. The manifest gave the glue’s abort section to niso-core instead of niso-abort, since AbortSignal stays in the core (architecture.md open question 11).
  3. cargo xtask split --cut-over wrote every section to its file, deleted niso_runtime.h and glue.js, and checked that composing from the files gives what the monoliths did. A section lives in crates/<owner>/include/<section>.h or crates/<owner>/glue/<section>.js, in niso-core’s tree while its owner crate does not exist (the step that makes the crate moves the file with git mv), in bindings/js/glue for the glue core, and in crates/niso-compose/frame for the composition’s own sections (head, tail, the generated-code and Intl markers, the globals list and tasks()).
  4. By hand, in the same commit: niso-base’s error-code test reads crates/niso-base/include/status.h; niso-capi’s render-version test reads the composed header in its OUT_DIR; niso-compose’s test over the real manifest reads the section files and checks that they still cut at their anchors; the contracts (niso-base, niso-host, niso-sched, niso-storage, niso-structured-clone, niso-canvas, niso-compose) and the docs that named the monoliths name the section files.
  5. The step’s gates. The composed glue is byte for byte the monolith’s and the header has the same lines, so WPT and the C, Java and Rust examples must not move.

From then on cargo xtask split reports the monoliths gone, and an edit goes to the section’s file.

A branch that still edits a monolith after the cut-over merges main up to the commit before it, resolves as usual, then merges the cut-over and runs cargo xtask resplit. For each section the branch changed, resplit runs git merge-file on the section’s file in main, the section as it was before the cut-over, and the branch’s section, then stages the file and removes the monolith; conflicts stay marked in the section files. A rehearsal on a scratch branch did this for a branch that edited three sections, one of which main had edited after the cut-over, and the composed header carried the branch’s new declaration. On 2026-11-09 the open branches that edit the monoliths (native-look, xhr, key-defaults, intl, svg-images, engine-napi, tv-input, text-fidelity, text-settings, conf-events) all cut cleanly with 6a’s manifest. Their composed headers compile in owner order, except text-fidelity, text-settings and conf-events, whose own conflicts with main break them (conflict markers, a duplicated typedef).

After the cut-over the anchors serve only resplit; 6c drops them once no open branch predates the cut-over.

Decided on 2026-11-10, with open questions 11 and 12 in architecture.md: AbortController and AbortSignal stay in niso-core and crates reach them through Cx, so step 11 is blob and fetch; XMLHttpRequest and FormData go to niso-fetch as optional glue; 6c is two agents and runs beside 6d and host prep.

Each agent owns its files, so 6c-gen, 6d and host prep run at once after 6b, and 6c-glue follows 6c-gen.

  • 6c-gen. niso-idl emits per IDL file with an owner crate. Each .webidl moves to crates/<owner>/idl, held in crates/niso-core/idl while the owner does not exist. The C shims call <owner>::bindings::<iface>, the owner’s generated Rust facade, which enters through one bracket in niso-host (niso_host::capi::call generalised, architecture.md 7.1) with the core’s call epilogue as CallBracket hooks. Interface ids become hashes (open question 5). idl/platform.webidl moves to niso-host. Files: niso-idl’s emit.rs, model.rs and lib.rs; niso-host’s capi.rs; niso-core’s build.rs and ffi.rs; niso-capi’s build.rs; the idl directory.
  • 6c-glue, after 6c-gen, whose hashed ids the generated glue needs. Glue sections become modules with install(ctx), and bindings/js/CONTRACT.md documents ctx; hook names replace enum qe_hook; the glue is composed in dependency order instead of the monolith’s. Files: bindings/js, niso-idl’s js.rs, niso-napi’s engine.rs, the Porffor prelude, the glue section files.
  • 6d. The host-provided interfaces become Rust traits with C adapters (architecture.md 7.5): ResourceLoader, Wake, DocumentHooks and WorkerHost, each with a contract suite run against its reference implementation and against the C adapter over it. Files: niso-host’s waker.rs; niso-core’s loading.rs and its hook fields and setters; niso-capi’s lib.rs setters.
  • Host prep, beside 6c-glue: what the wave 1 to 3 crates need from niso-host, added once so that no wave agent edits niso-host.
    • HostServices::origin and agent (websocket, fetch, webstorage, IndexedDB, messaging).
    • MessageEvent’s payload type (data, origin, lastEventId, ports) in niso-host, so websocket and messaging do not depend on each other. MessageEvent itself stays a core event (open question 3).
    • RenderingStep hooks (animations, media) and WorkerSeed (messaging, IndexedDB in workers).
    • AbortSignal through Cx: whether a signal is aborted and its reason, and adding and removing an abort algorithm. The document implements it over its signals, and FakeHost fakes it.
  • Source registration (0a), time_origin (0b) and RenderingStep (0c), the three niso-host steps in the table above. Source registration lands before 7 and 12 and before every later step whose crate registers a Source (9, 11b, 13, 14, 15, 16); time_origin lands before 8c; the RenderingStep change lands before wave 2.

The boundaries audit orders the checks every boundary needs in boundaries.md section 4. Its text is the specification; the waves take its items as follows.

  • Wave 0, items 1-7, before wave 1 starts. Item 1, cargo xtask contracts and cargo xtask gate, is under way on arch-xtask-gate. Item 2, the contract gaps that block wave 1, includes source registration (0a). Item 3, the permanent header goldens and NISO_ABI_VERSION, is under way on arch-6c-gen-abi, with 6c-gen. Item 4 is the cargo public-api snapshots, and item 5 is IDL validation in niso-idl, with 6c-gen. Item 6, the glue checks, goes with 6c-glue. Item 7, the implementor-side 6d suites, is under way on arch-6d-suites. Those three branches are not on origin yet.
  • Wave 1, items 8-10: every wave crate lands with its four artefacts (step 7 is reviewed against that list), the render stream conformance fixtures, and the template fixtures with a surface registry.
  • Waves 2 and 3, items 11-17: render stream version 3, the C ABI’s frame rules, the host contract and platform-token check, the JNI and Node package checks, the engine conformance suite (before step 19), the tooling formats, and the persisted formats (before step 9).
  • Wave 5, item 18: per-composition goldens.
  • A CONTRACT.md, testing fakes and a contract suite that runs on FakeHost and on the document; cargo test -p <crate> passes without niso-core in the dependency graph.
  • Its Rust API is the generated facade <crate>::bindings plus a hand-written api module, both entered through the bracket.
  • State lives in an ExtKey slot. Objects become PlatformObjects in the shared table, which deletes their ObjectData variants and Dom fields. Events fire through Cx::fire with typed payloads, which deletes their EventExtra variant.
  • Background work is a niso-sched Source with live. An interface the host provides is a trait with a C adapter (architecture.md 7.5).
  • It moves its files out of niso-core’s held ones with git mv: idl/.webidl, include/
    .h, glue/
    .js.
  • Wave 0: 6b and 6d (both on main), 6c-gen and host prep in parallel, then 6c-glue; source registration, time_origin and the RenderingStep change; boundaries.md items 1-7.
  • Wave 1, four extractions at a time: 7 websocket, the pathfinder whose review sets the template; 8a url, 8b encoding, 8c performance, 12 crypto, the fifth starting when one of the first four lands. Small, independent, one crate each. Boundaries.md items 8-10 land beside them.
  • Wave 2, four extractions at a time, after 7 is reviewed: 14 wasm, 15 modules, 16 animations, 10 webstorage, 11a blob, then 8d geometry and 8e base64 (after 8b). Boundaries.md items 11-17 run across waves 2 and 3.
  • Wave 3: 9 IndexedDB (after 10), 11b fetch (after 11a and host prep 2) and 13 messaging, with L2 prep beside them; host prep 2 (the lead), then 8f DOMParser and 13a navigation; consumers-1 (the lead, after boundaries.md item 11 and before L2 prep) and a11y-1 (after L2 prep), both only once the user has decided accessibility.md’s D1-D13.
  • Wave 4: 18a, 18b, 18c, 18d-1 to 18d-3 (18d-2 after 11b), 18e and 18f accessibility (niso-a11y, then its bridges), and step 19 as four agents (rquickjs, napi, jvm, Porffor with the UniFFI layer), starting once 6c, 6d and 14 are in.
  • Wave 5: step 20, with boundaries.md item 18.

Review is the limit rather than agents: four extractions at a time, each one crate’s diff plus deletions in niso-core, and each with one adversarial reviewer. Authors run on claude-code/claude-opus-5-5. Each reviewer is a fresh agent on claude-code/claude-fable-5-2 at high reasoning with no author context: it gets the branch, this plan and the contracts, never the author’s transcript.

  • Every agent branches from main and merges main; nobody rebases.
  • No wave agent edits niso-host, niso-sched, niso-idl or niso-compose; those belong to wave 0. An agent that needs one of them changed stops and asks, so the diffs stay disjoint.
  • In niso-core’s central lists (ObjectData, the Dom fields, EventExtra, api.rs’s modules, the capi shims, the run loop’s source registrations, the layering allowlist) an agent only removes lines. A conflict there is two removals, and both are kept.
  • For Cargo.lock, take main’s and rebuild. For workspace and niso-capi dependency lines, keep both sides.
  • Every step runs the gates and a WPT A/B against main at matched load, reports the metrics column, and changes no WPT expectation (no behaviour changes).

What stays in niso-core (re-audit, 2026-11-10)

Section titled “What stays in niso-core (re-audit, 2026-11-10)”

The rule: the core is the document, style and layout; everything else is a crate. This audit read every module of niso-core at arch-6c-glue (36,993 lines in src) against that rule.

Stays in the core.

  • The tree and the DOM itself:
    • tree.rs, host/tree.rs and the node, document, element, character data and parent/child node APIs in api.rs;
    • attributes, namespaces and named properties;
    • documents (DOMImplementation), template contents and serialize.rs (innerHTML);
    • mutation observers, traversal (NodeIterator, TreeWalker) and Range’s live updates;
    • custom elements, whose reactions run inside tree operations.
  • Events: dispatch, event targets and handlers, Event and CustomEvent, the error event for script errors.
  • AbortController and AbortSignal, as decided; crates reach them through Cx.
  • HTML element semantics that are document state: tables, aria, anchors, link, base and meta reflection, form owners, labels and select options.
  • Style and layout:
    • cssom, inline style, computed style, animation_style (as the StyleApplier), decoration, text_scale;
    • geometry.rs (layout border boxes), render.rs (the render stream), MediaQueryList;
    • ResizeObserver, which is driven by layout boxes in the rendering update.
  • Loading: the document’s HTML, its stylesheets’ resources and script elements (loading.rs), with the network reached through niso-fetch’s loader.
  • Infrastructure that shrinks as sources leave: event_loop.rs, hooks.rs, ffi.rs, host.rs, contract.rs.

Leaves.

  • Already planned, steps 7-18c:
    • websocket, url, encoding, performance, crypto;
    • webstorage, blob, fetch with XHR and FormData, IndexedDB;
    • messaging with workers, wasm, modules, animations, gamepad;
    • media, the canvas element and surfaces.
  • New from this audit:
    • 8d geometry, 8e atob/btoa/base64 and 8f DOMParser/XMLSerializer;
    • 13a navigation (History and Location);
    • 18d forms (text control selection, submission and the controls, as three stacked steps);
    • 18e UI events from host input.
    • 18f accessibility, a new crate rather than a move (its design is on a11y-design).

Not in niso-core today. Each of these except contenteditable and Screen has a parked branch that built it; “Conflicts with in-flight branches” below says what each must do before it lands.

  • Clipboard events: they are a crate (niso-clipboard) over a host Clipboard trait. clipboard-events built them in niso-core and niso-capi instead, so it is reshaped before it lands.
  • innerText: it stays in the core. Its getter is a query of the box tree (rendered text, display, visibility), so a crate would only forward to layout. It does not fit as a crate. inner-text built it that way.
  • Range, StaticRange and the Selection API: Range, StaticRange and Range’s live updates stay in the core, beside the tree’s mutation algorithms that move them; the Selection API is niso-selection, a document extension (L2) over the core’s Range API (selection-range).
  • The CSS Font Loading API: niso-fonts, a web API crate over a FontHost the document implements (font-loading).
  • Live NodeList and HTMLCollection: tree state, so they stay in the core with traversal (live-collections).
  • CSS background images and SVG images: painting, so they stay in the core’s render stream and in niso-canvas (background-images, and svg-images on top of it).
  • Contenteditable editing: only the attribute check and the onbeforeinput handler exist. An editing engine would be a crate on top of 18d and 18e.
  • The Screen object (about 40 lines in api.rs) can leave, but a crate costs more than it saves. It moves with the first CSSOM View crate that has a reason to exist.

Order.

  1. Wave 1: 7, 8a, 8b, 8c, 12.
  2. Wave 2: 10, 11a, 14, 15, 16, then 8d and 8e.
  3. Wave 3:
    • 9 (after 10), 11b (after 11a and host prep 2) and 13 (after 7);
    • host prep 2 (the lead), followed by 8f and 13a;
    • consumers-1 (the lead, after boundaries.md item 11), then L2 prep, then a11y-1; consumers-1 and a11y-1 wait on the user’s decisions D1-D13 of accessibility.md.
  4. Wave 4: 18a, 18b, 18c, 18d-1 to 18d-3 (18d-2 after 11b), 18e, 18f (which can start on a11y-1’s branch), and step 19.
  5. Wave 5: step 20.

Host prep 2 comes from the lead, as small stacked branches, each reviewed. Wave agents still never edit niso-host. It contains:

  • the document’s URL through Cx, and a navigation hook that anchors and Location call (13a);
  • AbortSignal made through Cx: creating a signal, making a dependent signal (AbortSignal.any) and signalling abort, which fetch’s Request.signal needs (11b);
  • a document factory that parses HTML into a new document and builds an XML document from a sink (8f);
  • hit testing and focus through Cx (18e).
  • a rendered view through Cx (computed display, visibility and inert, layout bounds, rendered text, focus, and each node’s render-stream id), and input targeted at a node rather than a point (18e; 18f reads a11y-1’s read view in the core instead, per accessibility.md D11).

Edges. niso-core’s allowlisted edges leave as follows:

  • niso-crypto with 12;
  • niso-storage with 9 and 10;
  • niso-structured-clone with the last of 9, 10, 13 and 13a;
  • niso-xml with 8f;
  • niso-canvas with 18b.

After wave 4, only the bindings’ edges remain, and step 19 removes them.

Until wave 5 is done, main moves only for this plan, apart from these exceptions the user approved:

  • docs, including the docs site (on main since ebe8d96a);
  • the blitz vendoring steps;
  • the wrapper-identity fix (wrapper-expandos);
  • the two reference renderers;
  • docs-only design and audit branches.

Everything else stays parked, including the branches named under “Not in niso-core today”.

  1. Small branches that land.

    • A branch holds one extraction, or one coherent step, and lands as soon as it is reviewed and green.
    • A branch merges main at least once a day and right before its merge check. Merge, never rebase.
    • Work that won’t land within about a day is split into stacked steps, and each step lands on its own, as 18d does.
  2. Web APIs live in crates.

    • Every web API that can leave niso-core goes into its own crate.
    • A new web API is born as a crate.
    • Adding a web API inside niso-core needs an exception written into this file by the architecture lead, which names the reason, as innerText’s does above.
  3. Every boundary is written down and machine-checked. A crate’s CONTRACT.md lists its whole public surface and numbers its guarantees, and each guarantee names its test. public-api.txt (cargo public-api) is committed, and a check fails when it and the crate disagree (planned: boundaries.md item 4). Its contract suite runs on FakeHost and on the document.

  4. Ratchets. These only shrink:

    • the layering allowlist;
    • niso-core’s central lists (ObjectData, the Dom fields, EventExtra, api.rs’s modules, the run loop’s sources).

    cargo xtask layers checks the allowlist. A count check of the central lists is the lead’s next niso-xtask step.

  5. Adversarial review.

    • Every architecture branch, the lead’s included, gets one reviewer, a fresh claude-code/claude-fable-5-2 agent at high reasoning with no author context, whose job is to break it: behaviour changes, hidden coupling, contract holes, layering cheats, and tests that would pass a planted bug.
    • The reviewer plants bugs and reports which ones survived. Every survivor gets a test before the branch merges.
    • Every merge into main is audited after it lands.
  6. Proof of no behaviour change.

    • A WPT A/B on one machine, in the order main, branch, branch, main, at matched load.
    • No expectation file changes.
    • The gates (workspace tests, the lang examples, layers, the crate’s tests, niso-core without default features).
    • A metrics column with sizes and test times.
Branch State Overlap with this plan Order
storage-fixes 17 ahead, 3 behind; merging next storage/* (all five files), event_loop.rs +40 (next_timer), rt.h +46, glue.js +27, lib.rs (run_tasks 1425-1452), worker.rs, indexeddb/mod.rs, niso-android, JVM Before step 1. Moving storage/ while it is open would make it a rewrite.
sched-followups 8 ahead, 25 behind websocket.rs +196, event_loop.rs +22, modules.rs +35, messaging.rs +20 Before step 4 (and so before 7 and 15). Steps 1-3 do not touch those files, apart from the Waker move in 3, which can wait.
plat-blob-abort-crypto 42 ahead, 65 behind; large (glue.js +2,144, net.rs +144, api/abort.rs and api/crypto.rs new, idl changes) ErrorCode (step 1), structured_clone.rs (step 2), ObjectData and Dom (step 3), fetch and abort (11), crypto (12), events.rs +28 Rebase it onto main before step 6. It can land any time before steps 11 and 12. If it lands after 1-3, it needs three small hand-ports (an error code, 10 lines of clone, the enum neighbours). After step 6 its 2,144 glue lines would have to be re-split by hand, so it must not wait that long.
engine-napi 38 ahead, 66 behind worker.rs +145, rt.h +31 (worker host), capi lib.rs +35, glue.js +184, niso_js.c +81 Before step 6 for the same glue reason, and before step 13. Its niso-napi crate becomes a composition in step 19.
tv-input on origin at 4ad33a9, 5 ahead, 0 behind; new files mostly (focus.rs, spatnav.rs, gamepad.rs, device.rs, capi tv.rs, gamepad.webidl) ObjectData and Dom (step 3), rendering steps (lib.rs 1238-1242), rt.h +120, glue.js +155 Before step 6 if possible. Otherwise its rt.h and glue additions need to become fragments. Gamepad extraction is step 17 either way.
native-look local only; not a checkout unknown: assessed from scratch files (Android build logs, widget scripts), which suggest host and render work rather than web API subsystems Ask its owner. If it touches render.rs or the hosts only, it does not conflict with steps 1-18.
inner-text on origin at 67b4090; 4 ahead, 81 behind, after the cut-over api/inner_text.rs (381, new) and a module line in api.rs, html.webidl +2, WPT expectations Fine as built: innerText stays in the core (“Not in niso-core today”). It merges main and lands; if 6c-gen is in first, its html.webidl hunk follows the file to its owner’s idl directory.
clipboard-events on origin at e8fea2a9; 3 ahead, 40 behind, after the cut-over api/clipboard.rs (472, new) in niso-core; four ObjectData variants (Clipboard, DataTransfer, DataTransferItemList, DataTransferItem); events.rs, input.rs, text_selection.rs; paste, copy and cut functions in rendering.h and niso-capi; niso-napi, niso-android and the iced, Apple and Android hosts; clipboard-apis.webidl Reshaped before it lands: ClipboardEvent, DataTransfer and the clipboard steps move into a niso-clipboard crate with a CONTRACT.md, no niso-core dependency and its objects as PlatformObjects, over a host Clipboard trait with a C adapter (architecture.md 7.5), so niso-core gains no ObjectData variants. What is document state (the focused target, the edit into a text field) is reached through Cx. It lands after 6c-gen, so it brings its own IDL; the hosts’ changes carry over.
selection-range on origin at 4360c40; 3 ahead, 65 behind, after the cut-over api/ranges.rs (937, new), ObjectData Range and StaticRange, tree.rs, mutation.rs, geometry.rs and text_selection.rs in niso-core; the new niso-selection crate; dom.webidl +73, selection.webidl; a status code in niso-base; the core’s animations glue Its shape fits the template in part. niso-selection is its own crate with a CONTRACT.md and a contract suite, and Range, StaticRange and Range’s live updates stay in the core. But it depends on niso-core (its layer is document-extension, built on the core’s Range API and Dom methods), and it has no testing fakes or suite on FakeHost, so it is an L2 crate rather than a web API crate. That is the right shape, since a selection’s range is a live range only the document moves. It merges main and lands as an L2 crate; Range and StaticRange are core state, as traversal is. Its text_selection.rs hunk is coordinated with 18d-1.
font-loading on origin at 515b0c8; 4 ahead, 25 behind, after the cut-over the new niso-fonts crate (web API layer, niso-host and cssparser only, with CONTRACT.md, testing and a FontHost suite, and its own glue module); fonts.rs (the core’s FontHost), net.rs +65 (font fetches), lib.rs; an allowlist entry niso-core -> niso-fonts; the bindings’ Cargo.toml Fits the template. Its allowlist entry is the new-web-API-crate exception of rule 4 and names its condition, so the list goes from 10 to 11 when it lands. That entry is added by font-loading’s own branch and checked by its reviewer, not by this docs branch. It merges main and lands before 11b, or its net.rs hunk is hand-ported into niso-fetch; once 0c and host prep are in, its drive from the rendering steps (niso_fonts::api::update) becomes a RenderingStep, and after 6c-glue its glue is a module with install(ctx).
live-collections on origin at b07f300; 2 ahead, 65 behind, after the cut-over api/collections.rs (354, new) and ObjectData::Collection in niso-core; api.rs, forms, tables, anchors, mutation; dom, html and html-elements webidl; the glue core (wrappers.js, conversions.js, global.js); qe_quickjs.c +282; the C, Go, Rust and Zig examples Stays in the core: collections are tree state. Its glue core and qe_quickjs.c changes conflict with 6c-glue (modules, hook names) and with step 19’s rewrite of qe_quickjs.c, so it lands before 6c-glue or is ported onto modules. It changes the C API the examples use, so if it lands after boundaries.md item 3 it updates the header goldens and bumps NISO_ABI_VERSION.
background-images on origin at a9d1cec; 15 ahead, 81 behind, with the cut-over merged background.rs (442, new), render.rs +225 and a BACKGROUND op in rendering.h with NISO_RENDER_VERSION 2 -> 3; Tile in niso-canvas; the iced, iOS and Android hosts’ readers; the Porffor stream reader; the lang examples Needs render stream v3 (boundaries.md item 11): its own bump to 3 would collide with v3’s in-band header and length-framed ops. It lands on v3, with its op number from item 9’s table, so it waits on items 9 and 11 (waves 1 to 3).
svg-images on origin at 2e289f1; 32 ahead (background-images included), 111 behind everything in background-images, plus SVG in niso-canvas (usvg, vello_svg), canvas.rs and api/canvas.rs, loading.rs and net.rs in niso-core, more render ops, canvas.webidl (DOMMatrix), security.md and a blitz repro Needs render stream v3, as background-images does, and lands after it. Its canvas element changes land before 18b moves the canvas element or are hand-ported into it, and its net.rs and loading.rs hunks before 11b or into niso-fetch.

The order that minimizes conflict pain:

  1. storage-fixes merges.
  2. Steps 1-3 land, while sched-followups and tv-input finish.
  3. sched-followups merges, then step 4.
  4. plat-blob-abort-crypto, engine-napi and tv-input are all rebased and merged before step 6, which splits rt.h and glue.js. That step is the one every open branch conflicts with, and those three branches carry about 2,500 lines of glue between them.
  5. Step 6, then steps 7-20 with as much parallelism as reviews allow.

If any of those three branches cannot merge before step 6, step 6 waits for it. Step 6 is mechanical and can be redone; their glue cannot.

cargo xtask metrics is planned and does not exist yet (xtask has layers, split, resplit and docs), and there is no CI to run it; steps 1-6 landed without it. When it lands, it records the baseline:

  • cargo build --timings for the workspace tests;
  • the per-binary test times (48 binaries, 5.3 s today);
  • the stripped niso-app size for aarch64-apple-ios;
  • needs_frame and next_timer cost with the full composition.

Each step reports the delta. The plan is on track when:

  • a moved crate’s cargo test -p runs without niso-core;
  • its incremental loop is seconds rather than the workspace’s 45-56 s;
  • the composed build’s size and loop cost are within noise of the baseline;
  • the layering allowlist only shrinks.