Plan
Phased. Status is updated as a phase completes, and a phase is not complete until its verification has actually been run.
Context
docs/first-thoughts.md sketches JigDAW as a web-native DAW plus web-native plugins: the
functionality a native DAW and its VST plugins carry today, with everything online,
WebAssembly throughout, and WebMCP built in.
The defining idea is that a plugin's identity, its metadata and its delivery collapse into one URI. Search, get a URL, HTTP GET, installed. That only works if the profile format and the host and plugin capability contract are right, so those are the foundation rather than the interface.
This is not greenfield. Four sibling repositories already run the pattern, described in local-references.md:
| Repository | What it contributes |
|---|---|
transmission |
the trn: vocabulary, the discovered and curated split, an RDF plugin catalogue with an MCP face |
downspout |
52 hand-written profile.ttl files, the format in real use |
valis |
instruments as RDF documents, the ontology to registry symmetry test, one dispatcher with thin adapters |
plugin-universe |
live at plugin-universe.com: hundreds of plugins, a published profile spec, a vocabulary at /ns, Fuseki, public SPARQL and MCP endpoints |
So the profile format is settled and already deployed. JigDAW's job is to extend it for the web: what a plugin's IRI serves, how WebAssembly and user interfaces are declared and fetched, and what the host guarantees in return.
The decisive constraint
first-thoughts.md says "do a HTTP GET and the plugin is installed". Three browser rules
stand between that sentence and working code, and the specification answers them explicitly
rather than discovering them later.
AudioWorklet.addModule()cross-origin requires CORS. A plugin served withoutAccess-Control-Allow-Origincannot be loaded, however good its profile. The response is unreadable rather than merely untrusted. Plugin hosting has a minimum bar.SharedArrayBufferrequires cross-origin isolation, which forces every cross-origin subresource anywhere to opt in withCross-Origin-Resource-Policy. Requiring it would mean an author publishes by publishing and then finds that publishing was not enough.- Executing fetched code is the whole threat model. A plugin is arbitrary WebAssembly plus arbitrary main-thread JavaScript from an arbitrary origin.
Resolved as: require CORS, negotiate shared memory rather than assume it, verify every resource against a declared digest, and sandbox plugin user interfaces cross-origin.
Decisions taken
| Question | Decision | Where |
|---|---|---|
| Plugin delivery | Profile first, then module. The IRI content-negotiates to RDF; the profile links to the code | plugin-profiles.md |
| Audio engine | One AudioWorkletProcessor per plugin; Web Audio owns the graph |
architecture.md |
| Ordering in a project | No rdf:List anywhere. Processing order is derived; everything else is keyed |
project-format.md |
| Feedback | A cycle must contain an explicit delay; latency inside a cycle is never compensated | latency.md |
| Identity and retrieval | Identity stays canonical, retrieval follows where the profile was fetched from | plugin-profiles.md |
| Vocabulary hosting | hyperdata.it/xmlns/jigdaw/, via the existing PURL wildcard |
namespace.md |
| Application hosting | strandz.it, following plugin-universe's arrangement |
deployment.md |
Phase 0. Specification. Complete.
The vocabulary, the shapes, the normative contract and the supporting documents. No implementation.
Delivered: vocabs/jigdaw.ttl; vocabs/shapes.ttl; the normative
host-plugin-contract.md, messaging.md,
latency.md, project-format.md; plus
plugin-profiles.md, webmcp.md,
namespace.md, architecture.md,
deployment.md; four worked examples, two valid and two counterexamples;
AGENTS.md with CLAUDE.md a symlink to it, and HUMANS.md listing the blockers.
Verified: every example parses; both valid ones conform; the profile counterexample produces eight violations and the project counterexample ten, one per constraint; a profile fetched live from plugin-universe.com conforms untouched.
Phase 1. The vocabulary in code. Complete.
src/rdf/Vocabulary.js, frozen constants, the single source of IRI truth.src/rdf/parse.jsandsrc/rdf/ProfileReader.js, which turn a profile into the object the host acts on, including the widget each port implies.src/validate/ShapeValidator.jsandbin/validate.js, sonpm run validateexists. It holds the SHACL section 3.6 severity correction thatrdf-validate-shaclgets wrong.tests/rdf/vocabulary.test.js, valis's ontology to registry symmetry test applied to the vocabulary: every term named in code is declared in the ontology and the reverse.
Verified by mutation: removing a constraint from the shapes, and adding an undeclared term to an example, each turn a test red.
Phase 2. One plugin, loaded. Complete headlessly.
The vertical slice, and the point where the contract stopped being prose.
plugins/cascade, a real Schroeder plate reverb in Rust,no_stdwith no allocator at all, compiled to WebAssembly. Itsbuild.shregeneratesprofile.ttlwith the digests of the artefacts that were actually produced.plugins/cascade/cascade-processor.js, the AudioWorklet processor.src/host/:PluginLoaderperforming contract section 3 in order,Integrity,Capabilities,Parameters,LoadErrorcarrying the step that failed.src/engine/Engine.js,src/ui/Panel.js,web/, andbin/serve.js, a development server that implements the contract's serving rules rather than convenient ones.tests/host/integration.test.js, which runs the whole path for real againstsrc/testing/OfflineHost.js: fetch, parse, validate, negotiate, verify digests, compile wasm, register, instantiate, render, and assert audio comes out.
Deployed 2026-09-17 to https://strandz.it/jigdaw/, under /jigdaw/ on port 6011
behind the existing nginx. Verified live: every URL returns the right status and media type,
both profiles validate against the shapes, and the digests in the live profiles match the
bytes served. deploy/ holds the nginx fragment, the systemd unit and check.sh.
Verified in a real browser, 2026-09-17. The synth loads, the on-screen keyboard plays it, a reverb chains after it, and the reverb tail outlives the synth's release.
That first run found four defects the headless suite had passed, one of them a specification
error: contract section 3.3 required posting a compiled WebAssembly.Module into an
AudioWorklet, which browsers silently refuse to deliver. The offline harness had been more
permissive than a real MessagePort, which is what let a wrong contract pass. All four are
in MISTAKES.md.
Changes the phase forced on the specification, which is what a vertical slice is for:
- Contract section 3.1 step 7 said post the module bytes while 3.3 said post the compiled
WebAssembly.Module. The contract is normative and cannot say both. - Resource locations had to be rebased onto the retrieval URL, or only the origin named in a profile could ever serve that plugin.
Phase 3. A graph. Core complete.
src/model/Project.js, the session as project-format.md describes it. Changesets are atomic, carryexpectedRevision, and supportdryRun. Editor metadata is held apart and does not bump the revision, so dragging a node cannot invalidate a compiled graph.src/compiler/GraphCompiler.js. Iterative Tarjan for strongly connected components, refusal of a cycle carrying less than one render quantum of delay, and latency compensation computed over the condensation.src/ops/OpDispatcher.js, the single operation layer. A changeset is compiled before it is committed, so a graph the compiler refuses never reaches the model or the audio, and what is playing always matches what the model says.src/engine/Engine.jsgainedlinkandclearLinks, which insert and own the delay nodes compensation asks for.
Verified end to end against real plugins in tests/host/integration.test.js: two plugins
load and link, a feedback loop between them is refused, and a graph needing compensation
produces a real delay node set to 512 frames at 48 kHz.
Two bugs this phase found, both recorded in MISTAKES.md:
- Latency stopped at the edge of a feedback loop, because dropping cycle edges before accumulating is not the same rule as not compensating across them. A dry signal beside a delay line would have arrived early with nothing to explain it.
- The id counter did not account for ids it had not minted, so replaying a saved project and then adding a node collided.
Remaining: wiring the dispatcher into the page, so the demo is a graph rather than one plugin.
Phase 4. Events and transport. Core complete.
src/engine/Transport.js, the musical clock. Beats to seconds and back across a tempo map, looping wrapped by modulo so an hour of playback costs what a second does, and the per-quantum message contract section 7 requires.src/engine/EventRouter.js. A MIDI connection is not an audio edge: it is the host carrying messages between two ports, so it never reachesconnect(). Events are ordered by frame before sending, and overflow reports are accumulated per node.src/engine/Engine.jsgainedonMessage, which fans one port out to several listeners. A port has oneonmessageand errors, outgoing events and dropped counts all arrive on it.plugins/pulse, an eight voice subtractive synthesiser in Rust:no_std, no allocator, no transcendental functions, pitch from a twelve entry table and an octave shift rather thanpowf. 3 KB of wasm.plugins/pulse/pulse-processor.js, with the bounded preallocated event queue. Events are applied in the quantum containing their frame, compared by range, and an event whose frame has passed is applied now rather than lost.
Verified end to end: a note sounds, a note off silences it, an event scheduled three quanta
ahead does not sound early and is not lost, a note on with velocity zero is treated as a note
off, overflow is reported rather than hidden, and a MIDI connection produces a host route and
no audio link. The MIDI capability shape was checked against the real plugin by removing
trn:requires jig:MidiEvents from it and watching validation fail.
Found this phase and recorded in MISTAKES.md: the router listened to a node only once it
had a route, so a node dropping events while unwired was invisible.
Remaining: a plugin that requires trn:HostTransport, to exercise the transport against real
DSP rather than only against its own tests.
Phase 5. The catalogue and the namespace
The compose stack with the SPARQL store, per deployment.md. Crawling profiles, named graph per source. Federating with or mirroring plugin-universe rather than starting empty.
Serving the vocabulary at its namespace IRI belongs here and is independent of everything
else in the phase. It is a deployment rather than a build, and HUMANS.md carries it
because it needs a person.
Phase 5. The catalogue. Search complete.
Search works without a store of our own, because plugin-universe already runs a public read-only SPARQL endpoint over its own large CC0 catalogue and building a second one of the same things would be worse than querying that one.
src/catalogue/:QueryServiceloading.sparqlfiles by name,terms.jswhere a value becomes syntax and nowhere else,facets.jsholding the one facet list, andCatalogue.sparql/queries/catalogue/. No SPARQL is inlined in JavaScript, andtests/docs/conventions.test.jsfails if any appears./catalogue/searchand/catalogue/describeon the server, so no SPARQL reaches the browser bundle and there is one place to cache. It also means the page does not depend on an upstream endpoint's CORS headers being right.A search box with facets in the page.
src/catalogue/LocalCatalogue.js, which indexes the plugins this host serves itself by reading the sameprofile.ttlfiles it serves. No store, and no second copy to drift.
Search returns this host's own plugins first and, by default, only those. A browser listing
hundreds of plugins that none of them can run is a list rather than a browser. The rest of the
catalogue is one checkbox away, dimmed and labelled, because hiding it entirely would
misrepresent what exists: those are real plugins, and jig:WebPlugin being a subclass is
exactly what lets the catalogue hold both.
Remaining: crawling, and getting JigDAW's own plugins harvested upstream so other hosts can find them. Neither is needed for search to work.
Phase 6. WebMCP. Complete.
src/mcp/tools.js, fourteen tools over the dispatcher:status,project_get,plugins_search,plugin_describe,plugin_validate_chain,plugin_load,graph_apply_changes,connection_add,parameter_set,transport_configure,diagnostics. Not one of them implements an operation of its own.src/mcp/adapter.js, the only file that knows how a user agent learns of a tool. WebMCP is still moving, so the surface is specified and the binding is isolated: it binds tonavigator.modelContextwhere that exists and to the page otherwise, and says which.
Search is demand driven, as webmcp.md requires: plugins_search returns enough
to choose between candidates and plugin_describe is called only for the few that matter.
Every failure is a result rather than a rejection, including a tool that throws, because an agent cannot read a stack trace and the message is the entire interface to a failure. A missing catalogue makes the discovery tools explain themselves rather than vanish.
Documentation for people outside the project
web/docs/ is served at /jigdaw/docs/ and linked from the front page alongside the
repository:
- an overview and index, explaining the one idea and the three vocabularies, with an honest account of what works and what does not;
- a guide for host authors: the load sequence, capability negotiation, and the traps that cost real time here;
- a guide for plugin authors: writing a profile, the processor, the WebAssembly, digests, and what a server must send.
These are guides. The normative specifications stay in docs/ and are linked from the index,
which says plainly that they are the authority where the two disagree.
tests/docs/conventions.test.js fails on a broken link, a link to the wrong repository, a
missing viewport or a missing title. The repository check exists because leaving the account
name danja out of a github.com link is somebody else's account and returns 200, so that
typo would not even have looked broken.
Phase 7. A native host. Complete.
native/jigdaw-adapter is a VST3, CLAP and LV2 built with DPF, in the shape downspout uses:
a portable core with the plugin format as a thin shell over it. Two channels of audio in and
out, MIDI in and out, and a chain of JigDAW plugins loaded by IRI.
src/Turtle.cpp, the subset of Turtle a profile uses, about 300 lines and no dependency.src/Profile.cpp, including the rebasing that keeps identity canonical while retrieval follows the mirror.src/Integrity.cpp, sha384 through OpenSSL, agreeing with the browser host's digests.src/Module.cpp,jig:Abi1andjig:Abi2through wasm3, and the only file that knows which runtime.src/Chain.cppandsrc/dpf/.
Verified: both worked plugins fetched over HTTP, verified against their declared digests, instantiated and sounded. Pulse silent, then 0.133 on a note, then silent on release. Cascade passing dry signal through bit-exactly at mix 0 and producing a tail over 189 of 200 blocks. A chain of both: the synth through the reverb, with the tail outliving the note.
Four native tests run under ctest. The one that fetches over HTTP skips, loudly, when nothing is serving, because a network test that fails a build is a test that gets disabled.
install.sh, at the repository root, builds, tests and installs into ~/.vst3, with
--all for the CLAP and LV2 as well. It finishes by dlopening what it installed and calling GetPluginFactory,
because a copy that succeeded says nothing about whether a host can open the result. Verified
on the installed bundle: no unresolved libraries, the execute bit intact, and a factory
returned.
A third host, written elsewhere
Transmission now hosts JigDAW plugins
alongside VST3 ones. It does not reimplement the contract: its native/CMakeLists.txt adds
native/jigdaw-adapter from a JigDAW checkout and links jigdaw_core, which is the first use
of that library outside this repository and the vindication of separating it from the DPF
shell. Both ABIs, port counts read from the profile rather than from the project, a panel
generated from the same lv2:port declarations the browser host generates one from, and
file:// plugin IRIs for development.
It is weaker evidence than an independent implementation would be, and it is a different kind
of evidence from any test here: a JigDAW plugin runs in an application written for something
else. It has already returned one defect, the locale-dependent number parsing in
Profile.cpp in MISTAKES.md. Nothing in this repository would have found it, because no
test here runs under a comma-decimal locale and no host here calls setlocale.
Phase 8. Sessions. Complete.
The project format has been normative since phase 0 and nothing wrote it, which made every
claim in docs/project-format.md a claim about a file no code produced, and meant a session
could not survive closing the tab.
src/rdf/ProjectWriter.jsandsrc/rdf/ProjectReader.js. Skolemised fragments, no blank nodes, deterministic output, and editor positions in their own document because dragging a node must not invalidate a compiled graph.- The reader returns a changeset rather than a Project. The model has one way in, and a reader that built state directly would be a second implementation of every rule that lives there, enforced on a project a person built and not on one they opened.
OpDispatcher.addPlugintakes an explicit node id, because the connections in a saved file name the nodes they join.- Save and Open in the transport bar.
tests/rdf/ProjectRoundTrip.test.js round trips a project with one of everything the format
can express, checks the written Turtle against vocabs/shapes.ttl, and reads the worked
example the documentation points people at.
Verified in a browser: a chain of BassGen into Pulse with two parameters set, saved, cleared, and reopened from the saved bytes. Ids, plugin IRIs, settings and the MIDI connection all came back, exactly two engine nodes existed afterwards, and it played at peak 0.373.
It found that removing a node never removed the AudioWorkletNode behind it. See MISTAKES.md.
Phase 9a. Bundles. Complete.
"Install is a HTTP GET" works for as long as somebody is serving the plugin, which is not the same as for ever and not the same as on a machine with no network. There was no way to hand a plugin to a person as a file.
plugin-bundles.md defines two forms, and the first turned out to exist
already without anybody noticing: a profile whose jig:location values are data: URIs is
a legal profile, and the loader reads it unchanged. resolveLocation leaves a data: URI
alone, fetch reads it, and the integrity digest verifies over the decoded bytes exactly as
over bytes from a server. The second is a .jig zip with profile.ttl at the root, which
unpacks into a working plugin origin.
Neither changes what a plugin is. Both carry the canonical IRI, and a bundle is a retrieval
origin rather than an identity, which is the rule rebaseLocation already implemented for
mirrors.
bin/bundle.js, no dependency: the zip container is written overnode:zlib. Deterministic, with a fixed date, for the same reason the profile writer is.- It verifies before it packs. A profile whose declared digest does not match the file beside it is refused, because that failure is local until the bundle reaches somebody else.
tests/host/bundle.test.jsloads a flattened profile through the realPluginLoaderand unpacks an archive into a directory the loader treats as an origin. Mutation tested.
Measured on the worked plugins: cascade is 227 kB of wasm, 314 kB flattened and 5 kB archived. The two forms are for two jobs and the numbers say why.
That left one thing open, recorded in the document rather than invented: every file in a bundle is tamper evident because the profile digests it, and the profile itself was not. Phase 9b closed it.
Phase 9b. Provenance and signing. Complete.
A bundle arrives by hand, from somebody, and nothing in it said from whom. Every bundle now
carries a provenance.ttl: what it is a copy of, who made it, when, with what, and a
jig:canonicalDigest of the profile itself. Given a key, bin/bundle.js also signs.
The digest is over the graph, not the bytes, which is the decision the rest follows from.
The same plugin is legitimately three different byte sequences, flattened, archived and
served, so a digest that changes between them tells a recipient nothing. The canonical form is
RDFC-1.0 restricted to graphs with no blank nodes, which is sorted N-Triples, with every
jig:location omitted. Omitting location is what makes one value hold across all three, and
it is safe only because jig:integrity is not omitted: a rewritten location can point only at
bytes the signature already covers.
Signing reuses sec: rather than inventing a parallel. Data Integrity proofs and Multikey
are what a signature over RDF is already written in. The suite is named jigdaw-eddsa-2026
rather than eddsa-rdfc-2022, because ours omits jig:location and a proof that differs
needs a name that differs. Ed25519 over WebCrypto, so the page and the command line verify
with the same code. The signature is over two digests, the proof's own configuration and the
document, which is how the proof's time and key are covered without a signature containing
itself, and is what lets a second person countersign without breaking the first signature.
The report is three answers, never one tick. Are these the files the profile names, is
this the profile that was bundled, and who says so. A valid signature by an unknown key proves
that one holder of that key made this and nothing about who they are, and bin/verify.js
says that in those words. There is no trusted key list and there will not be one.
What it found. bin/write-profile.js had been emitting lv2:scalePoint as blank nodes
since phase 2, in a repository whose conventions have forbidden a blank node for anything
addressable since phase 0. Nothing noticed until a signature needed a canonical form and the
first real plugin refused to canonicalise. Both are now guarded: every Turtle file in the
repository is canonicalised by tests/rdf/Canonical.test.js, and the panel each committed
profile generates is checked against the scale points it declares, which nothing had ever
read end to end. See MISTAKES.md.
bin/keys.js refuses to write a private key inside a git working tree. No key material is
committed and the tests generate theirs in memory.
Phase 10. Web Audio Modules. Complete one way.
JigDAW was specified without a single mention of Web Audio Modules, the existing standard for exactly this. That was an omission, not a decision, and it was found by being asked to look at the API rather than by anything here noticing.
bin/wam.js packages a plugin as a WAM: same profile, same WebAssembly, same processor, with
descriptor.json and an index.js generated from the profile. 21 kB and standalone, because
the profile is resolved at build time rather than parsed at load; a Turtle parser in a browser
costs 1.7 MB and two shims. See wam.md.
It carries integrity into a format that has no concept of it. No digest, no hash, nothing,
across the whole of @webaudiomodules/api. The digests come from the profile and are checked
before anything is registered.
It runs one way. A WAM's file set is not knowable before running it, because its audio
thread dependencies arrive through addFunctionModule rather than through anything a manifest
lists, so it cannot be declared, digested or verified. JigDAW's format is a description and
WAM's is a program, and one converts to the other only in that direction.
What it needed. doap:revision, because WamDescriptor.version is required and no profile
carried a version. Reused rather than minted as jig:version: DOAP is what LV2 uses to
describe a plugin project, and this vocabulary already follows LV2.
What it found. PluginLoader cannot be bundled for a browser at all: it reaches
ProfileReader and therefore @zazuko/env, which reaches node's stream and util. Steps 4
to 8 of contract section 3.1 are now src/host/Instantiate.js, which knows nothing about RDF,
with PluginLoader still the front door. No caller changed and its 111 tests passed unedited,
which is what says the split was a move rather than a rewrite.
Three defects in the adapter were found by the offline test rather than by review: it assumed
the node was an EventTarget, then that the port was, and the parameter typing inferred int
from bounds that happened to be whole numbers, which would have quantised every cutoff in
every WAM host to 1 Hz steps.
Open: audio-thread connectEvents, which needs the shell to drive the module through
module-abi.md rather than wrapping the processor, the way
native/jigdaw-adapter already does for VST3.
Phase 10b. Foreign plugins. Specified and gated; the adapter is not built.
Loading a WAM into a JigDAW host turned out to be blocked by something other than what
wam.md first said. addFunctionModule takes a function reference, so it stringifies
code already loaded rather than fetching, and the enumeration problem is narrower than claimed
and is solved by verifying a container instead of enumerating contents.
The real obstacle is that a WAM's entry point is a module the host imports into its own document and calls, with the host's origin and privileges. Contract section 9.1 forbids that and gives the reasons. It is not a packaging problem and cannot be packaged away.
Contract section 12 defines a separate class rather than relaxing section 9.1. A foreign
plugin is jig:ForeignPlugin, disjoint from jig:WebPlugin, and a host that supports one must
verify its container, take consent bound to that container's digest, and mark it wherever it
appears. Support is optional; refusing everything still conforms. Sections 1 to 11 are
untouched, which was the point: the alternative was quietly weakening two of them.
Built and tested: the reader, the consent gate, the container loader with its traversal rules,
the shapes, a worked example and a counterexample that fires eleven constraints. The
disjointness is a SHACL constraint and not only an owl:disjointWith, because nothing
validating a profile loads the vocabulary.
The virtual origin is built and has run. web/foreign/sw.js and
src/host/ForeignOrigin.js, checked by web/foreign/probe.html because no test here can
reach a service worker. Measured in Chrome, 2026-09-18, against pingpongdelay from
webaudiomodules/wam-examples in a 351 kB container: 14 of 14, ending in a real Web Audio
Module passing audio at peak 1.0000 out of bytes that were verified before anything ran.
The browser found two things review had not. A host must inject WamEnv and a WamGroup into
its own worklet before any WAM instantiates; that code is shared, is in no container, and is
the host's rather than a plugin's, which is now contract section 12.3a. And section 12.3
contained a MUST no host can satisfy: a .. path is normalised by the browser before a
service worker sees it, so it leaves the scope and is never offered for refusal. The wording
now says what is enforceable and states the limit.
The adapter is written too. src/wam/WamAdapter.js gives a WamNode the shape the engine
expects: a parameter map over setParameterValues, and a port translating JigDAW's messages
into WAM's methods and its events back. The panel comes from getParameterInfo() rather than
from the declared ports, because the plugin is the authority on its own parameters, and
widgets follow the same rule section 5.3 gives a native plugin.
18 of 18 in the probe, ending with a real Web Audio Module adopted as an engine node, a parameter moved through the engine's own path and read back from the plugin, and audio still passing. What cannot be translated is a test rather than a comment: WAM takes no time with a parameter, so a ramp lands immediately.
Two guards came out of doing it. The probe's inline module is now syntax checked, because an
edit that shadowed a name made the page render, the button do nothing and window.probe stay
undefined, with no error anywhere. And the run needs a real click: a scripted one is not user
activation, so the AudioContext stays suspended and every audio check reads zero.
Measured against the real plugins, once ~/wam-examples existed, which turned three
guesses into findings. addFunctionModule stringifies an already-loaded function and fetches
nothing, as the SDK source shows, so the correction to the original blocker holds. 15 of the
23 example plugins fetch at run time, for their own descriptor, GUI templates and preset
banks, which is why a container is verified rather than a file list enumerated. And 22 of the
23 locate themselves with import.meta.url, which rules out serving the container as blob
URLs and makes the Service Worker required rather than preferred. All three are asserted in
tests/host/ForeignLoader.test.js against the checkout when it is present, and skip loudly
when it is not.
One case the boundary correctly breaks: PedalBoard-WAC2022 is a plugin that is itself a
plugin host, fetching a repository list and importing whatever it names. Its reachable code is
not a knowable set, every one of its fetches resolves outside the container, and it does not
work. That is the right answer rather than a gap.
Two defects found by checking rather than by reading. The jig:entryPoint pattern allowed
.., because .. matches [A-Za-z0-9._-]+; the counterexample still produced the expected
number of violations by way of a different constraint, and it was caught by asking which rule
fired rather than how many did. And the panel's mark was nearly a CSS class alone, which
section 12.5 forbids and which no test that only counted elements would have noticed.
Phase 10c. Foreign plugins in the application. Complete.
Everything section 12 requires of a host, rather than merely permits, now happens. The
machinery existed and nothing in src/ops, src/ui or web/app.js referred to any of it, so
nobody was asked for consent, nothing was marked, and the whole of Tier 2 worked in a probe
and nowhere else.
Engine.adopt is the seam: everything after instantiation is the same whoever made the node,
so the foreign path is a branch in one dispatcher rather than a second loader.
ForeignSupport is built only if a host asks for it, which is the whole of the decision to
support foreign plugins at all. A ConsentRequired travels out of the dispatcher intact so
the asking happens where a person is, and the dialog renders the statements ForeignTrust
froze rather than wording them again.
Verified in Chrome: a Web Audio Module fetched by IRI, classified, consented to, verified,
served from the container worker, adapted, adopted, drawn from getParameterInfo(), marked
FOREIGN in the rack and on its panel, and passing audio to the speakers beside a native
Cascade that is not marked.
Two service worker faults on the way, both invisible to the probe, which sits inside the
worker's own path and so was the one page where neither can happen:
navigator.serviceWorker.ready never resolves for a page outside the scope, and a worker only
intercepts requests from clients it controls. Scope and prefix are different things.
And one thing reported as a defect that was not one. After those were fixed the load
stopped settling, and I wrote that into MISTAKES.md as an undiagnosed hang. The tab had
become hidden, which grants no user activation, so AudioContext.resume() never returned.
Proved by re-running the probe, which had passed 18 of 18 an hour before on the same code and
then produced nothing at all. The correction is in MISTAKES.md and the rule is in
AGENTS.md; loadForeignPlugin now names the stage on a hang, which is what should have
answered it in one run.
Phase 9. The graph the model already had. Complete.
The model has been an arbitrary directed multigraph since phase 3, with Tarjan SCC, cycle refusal and latency compensation over the condensation. Only the interface insisted on a chain, and five things underneath it were wrong in ways that had never been looked for.
The five. Nothing in the compiled graph reached the speakers: Engine could resolve a
destination of output and the dispatcher never passed it, so the page connected each plugin
itself and an effect in the middle of a chain was heard twice. A connection to a parameter was
wired to audio input zero, so modulation was expressible, validated, exposed by a tool, and
delivered nowhere. connectionKey left out the signal kind, so an audio edge and a MIDI edge
between the same two ports collided. Removing a node severed the path. And setParameter wrote
the asked-for value and then the clamped one, which is two revisions for one movement of one
slider.
The channel strip. jig:gain, jig:pan, jig:muted, jig:soloed on jig:Node: host
services rather than plugin parameters, because no lv2:port declares them and solo is a
property of the whole graph. The dispatcher resolves solo and the engine never sees it.
Routing. Ports on each slot and a connection list derived from project.connections,
rather than a node canvas. A canvas loses on both of this project's stated constraints: a drag
between two points is hard to operate from a keyboard, and hit testing a graph at phone width
is a fight the layout loses. Choosing an output and then an input is two ordinary buttons. The
wire between two slots is now drawn only where a connection runs, so a branch is no longer
drawn as a chain.
Also. Three of the tools webmcp.md specified and nothing had built, and a guard binding
the tool list to that document in both directions. tests/engine/Engine.test.js, which did not
exist: the engine had only indirect coverage through a fake that connects nothing, which is
exactly where the parameter endpoint was being lost.
Verified in a browser at each step, which found three defects no test had: a saved mix written
and read correctly and dropped by addPlugin on the way back in, two instances of one plugin
sharing one name, and the connection list never being appended at all. All three are in
MISTAKES.md.
Phase 11. Behaving like a real DAW. In progress.
Not a phase with an end: the specification is complete and normative (phase 0), and this is
the open-ended work of the browser host behaving like the instrument it claims to be, on top
of it. Detail is in TODO.md's "The application" and "JSFX plugins" sections rather than
repeated here; this is the summary.
Five more worked plugins beyond phase 2's Cascade and Pulse: BassGen, a transport-synced bass
line generator; 8-Bit 8asterd, the firmware of a three-chip AY-3-8910 synthesiser compiled
unedited from C++; Dynamix, a compressor/expander, limiter and clipper in series with a side
chain input; and three REAPER JSFX effects converted by bin/jsfx-import.js, which runs a
restricted EEL2 subset under a shared interpreter (plugins/_jsfx-runtime/) rather than
compiling a module per effect. The last needed one real contract-adjacent addition: jig:asset
had been declared and read for a while with nothing delivering its bytes to a running plugin,
so src/host/Instantiate.js now fetches, verifies and posts every declared asset the same way
as the module and the processor (docs/messaging.md section 1.2).
The rack view split into a Tracks tab (unchanged) and a Mixer tab (one channel strip per
audio-producing node, gathered rather than embedded per node), both built from
src/ui/Tabs.js rather than a second implementation of tab switching. Undo and redo
(OpDispatcher.undo()/redo()) reconcile the live project to a snapshot through the same
apply()/addPlugin()/setParameter() calls a person or WebMCP would use, so the engine
moves with the model exactly as it does for any other edit; a node removed since the snapshot
is reloaded from its IRI, the same path reopening a session takes.
Still thin: no timeline, no recording, no plugin user interfaces of their own. See index.md for the current honest summary, published alongside the specification.
What it found
native/jigdaw-adapter is a VST3, built with DPF in the shape downspout uses, that loads
JigDAW plugins by IRI.
It was proposed as a sanity check on the specification and it worked as one immediately: a
native host could not load a JigDAW plugin at all. The only thing the contract guarantees is
a JavaScript AudioWorklet module, and the WebAssembly ABI is explicitly private to the
plugin. The specification had accidentally made itself browser-only.
module-abi.md is the answer: a module may declare jig:abi jig:Abi1 or
jig:Abi2, and a
host with a WebAssembly runtime loads it directly. Both worked plugins declare it, every port
now carries a jig:paramIndex, and the shapes refuse a plugin that declares an ABI without
one.
Blocked on a person
In HUMANS.md, and unchanged by any amount of building: serving the vocabulary, minting a
web plugin format term, deciding which repository owns trn:, and fixing trn:
dereferencing. The format term blocks publishing a JigDAW plugin to the catalogue; the rest
block nothing here but leave published IRIs dead.