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.
Rules every step follows
Section titled “Rules every step follows”- 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.
- Move first, then improve. A step that moves code into a crate changes paths and visibility, and nothing else. niso-core keeps a
pub usere-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. - The extension path exists before anything uses it. Step 3 adds
ObjectData::Ext(Box<dyn PlatformObject>)and a slot table toDom, 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. - 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.
- Each new crate arrives with
CONTRACT.md, itstestingfakes and itscontractsuite (architecture.md section 8), and withcargo test -p <crate>green without niso-core in its dependency graph. - 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.
The first three steps
Section titled “The first three steps”Step 1: niso-base
Section titled “Step 1: niso-base”Creates crates/niso-base (no niso dependencies; no third-party ones).
- Moves:
ErrorCode,ErrorandResult(lib.rs:74-131);Objand its top-bit and generation helpers; the C value types and conversions from ffi.rs (NisoStrandNisoBytesreading, theCallbacktriple 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.InterfaceIdstays the generated enum in niso-core for now; hashed ids come with niso-compose (step 6), because they change the valuesniso_object_interfacereturns (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
ErrorCodeare ABI. Its contract test asserts each value against theNISO_*constants that rt.h defines, so a renumbering fails without a host. - Done when:
cargo test -p niso-basepasses, 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.
Step 2: niso-structured-clone
Section titled “Step 2: niso-structured-clone”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-writtenniso_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
DataCloneErrorrather than a panic, andjs_stringagreeing with the JS engine’s string order. These are checked by property tests over the corpus. - Done when:
cargo test -p niso-structured-clonepasses in a few seconds (architecture.md 9.3), with no niso-core in its tree. storage/key.rs and indexeddb keepcrate::structured_clone::js_stringthrough 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:
Wakerfrom modules.rs:43-79 (net.rs, messaging.rs and modules.rs already share it), unchanged in behaviour. - APIs introduced:
Cx,HostServices,ExtKey<T>andHostState(slots, deferred frees, the borrow record, hooks),PlatformObjectwithheld,freedandheap_size,EventInitandDispatched,TaskSource,Inbox<T>,Registrar(interfaces, slots and hooks; sources come in step 4), andtesting::FakeHost. Exact shapes are in architecture.md section 3.2. - Changes to niso-core, the minimum needed to host:
Domgains one field,host: Box<HostState>.ObjectDatagains one variant,Ext(Box<dyn PlatformObject>).interface_of(lib.rs:737-827) andfree_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 Domforwards to what exists:now,event_looptimers,Taskwith a newTask::Ext(Box<dyn FnOnce(Cx) -> Result<()>>)variant forqueue_task, anddispatchthroughDom::dispatch(events.rs:343) with explicit parents for object targets, which IndexedDB’sidb_parentwalk (events.rs ~296-312) does today.niso_host::capi::callwrapsffi::call(ffi.rs:194) to hand aCxto shims.
- Contract:
niso_host::contract::host_suitecovers object identity and generations, release order,freedrunning at a safe point,firereturningcanceledandlistener_threw, and the rule that no slot borrow is held across script. The suite runs against bothFakeHostandDom, and that is the step’s main deliverable: it is what lets every later crate test onFakeHostalone. - Done when: both hosts pass
host_suiteand nothing uses the new path yet. A throwaway test object registered throughRegistrarproves 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
ObjectDatavariants andDomfields (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, theWakermove waits and step 3 re-exports it from niso-core instead.
- plat-blob-abort-crypto and tv-input both add
Then, in order
Section titled “Then, in order”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.
Step 6b, the cut-over
Section titled “Step 6b, the cut-over”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:
cargo xtask splitpassed on main: the glue composed byte for byte and the header to the same lines.- The manifest gave the glue’s
abortsection to niso-core instead of niso-abort, since AbortSignal stays in the core (architecture.md open question 11). cargo xtask split --cut-overwrote 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 incrates/<owner>/include/<section>.horcrates/<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 withgit 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 andtasks()).- 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. - 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.
Steps 6c onward, in parallel
Section titled “Steps 6c onward, in parallel”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.
Wave 0: what every crate needs, once
Section titled “Wave 0: what every crate needs, once”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::callgeneralised, architecture.md 7.1) with the core’s call epilogue asCallBrackethooks. 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 documentsctx; hook names replaceenum 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,DocumentHooksandWorkerHost, 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::originandagent(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).
RenderingStephooks (animations, media) andWorkerSeed(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, andFakeHostfakes it.
- Source registration (0a),
time_origin(0b) andRenderingStep(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 aSource(9, 11b, 13, 14, 15, 16);time_originlands before 8c; theRenderingStepchange lands before wave 2.
The boundary checks, by wave
Section titled “The boundary checks, by wave”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 contractsandcargo 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 andNISO_ABI_VERSION, is under way on arch-6c-gen-abi, with 6c-gen. Item 4 is thecargo public-apisnapshots, 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.
The shape every extraction follows
Section titled “The shape every extraction follows”- A
CONTRACT.md,testingfakes and a contract suite that runs onFakeHostand on the document;cargo test -p <crate>passes without niso-core in the dependency graph. - Its Rust API is the generated facade
<crate>::bindingsplus a hand-writtenapimodule, both entered through the bracket. - State lives in an
ExtKeyslot. Objects becomePlatformObjects in the shared table, which deletes theirObjectDatavariants andDomfields. Events fire throughCx::firewith typed payloads, which deletes theirEventExtravariant. - Background work is a niso-sched
Sourcewithlive. 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_originand theRenderingStepchange; 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.
Rules that keep parallel branches apart
Section titled “Rules that keep parallel branches apart”- 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, theDomfields,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.
- cssom, inline style, computed style, animation_style (as the
- 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
Clipboardtrait. 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
FontHostthe 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
onbeforeinputhandler 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.
- Wave 1: 7, 8a, 8b, 8c, 12.
- Wave 2: 10, 11a, 14, 15, 16, then 8d and 8e.
- 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.
- 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.
- 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’sRequest.signalneeds (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.
Keeping the plan from drifting
Section titled “Keeping the plan from drifting”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”.
-
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.
-
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.
-
Every boundary is written down and machine-checked. A crate’s
CONTRACT.mdlists 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 onFakeHostand on the document. -
Ratchets. These only shrink:
- the layering allowlist;
- niso-core’s central lists (
ObjectData, theDomfields,EventExtra, api.rs’s modules, the run loop’s sources).
cargo xtask layerschecks the allowlist. A count check of the central lists is the lead’s next niso-xtask step. -
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.
-
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.
Conflicts with in-flight branches
Section titled “Conflicts with in-flight branches”| 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:
- storage-fixes merges.
- Steps 1-3 land, while sched-followups and tv-input finish.
- sched-followups merges, then step 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.
- 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.
Measuring that it worked
Section titled “Measuring that it worked”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 --timingsfor the workspace tests;- the per-binary test times (48 binaries, 5.3 s today);
- the stripped niso-app size for aarch64-apple-ios;
needs_frameandnext_timercost with the full composition.
Each step reports the delta. The plan is on track when:
- a moved crate’s
cargo test -pruns 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.