The problem, stated plainly
Cordial embeds Deno to run plugins, and the justification on the record is sandboxing: a plugin is untrusted code, so it runs somewhere it cannot reach the filesystem or the network except through a broker. That justification is circular while plugins have nothing to compute. Look at what the one real plugin does. FPS Flex reads a few preference values, maps them to a set of flag names, and writes a JSON document that is consumed at the next launch. Every capability it holds —flags.read, flags.write,
settings.read, log — is spent producing a static file. That is a build
step. A declarative flag profile with conditionals would do the same job, and
the sandbox would be protecting a program that never makes a decision.
If that is the ceiling, the honest conclusion is to delete the runtime and ship
declarative flag presets. This ADR argues the ceiling is wrong, and says what
has to be true instead.
Decision
A plugin is a program that observes the running client, decides something, and acts on it. Cordial provides all three, or it should not provide a runtime at all. Three things are added, and they are one thing in three parts:- An event bus. A plugin subscribes to what the client is doing.
- Live effects. Some acts take effect now rather than at the next launch, and the API never lies about which.
- Contributed UI. A plugin can draw, in Cordial’s own window, over the engine’s canvas.
The admission rule for any future capability
Could a static configuration file do this? If yes, it is configuration and belongs in the manifest, where ADR-020 already puts preferences and ADR-021 already puts assets and flags. If it requires observing something that is only known while the client runs, deciding from it, and acting before the moment passes, it is a program — and then the sandbox is containing something that genuinely has reach, which is the only condition under which embedding a JavaScript runtime is worth its cost. Applied to what exists today, this rule says FPS Flex should largely be declarative, and that the interesting version of FPS Flex — one that watches frame time and gives quality back when a scene gets cheap — is a program.1. The event bus
cordial-plugins/src/events.rs has an EventRegistry and essentially nothing
flowing through it. The client already knows everything below and tells nobody:
Rules, each of which exists because of something already learned here:
- Delivery is best-effort and never blocks the client. A plugin that is slow, wedged or crashed must cost frames from nobody. The engine’s pump is the one thread this project has repeatedly had to defend; nothing subscribed gets to stall it.
- Events are facts, not requests. A plugin cannot veto a join or swallow a keystroke. Anything else is an in-process hook wearing a subscription, and ADR-001 rules that out permanently — not disabled, absent.
- Payloads carry no secrets. No
.ROBLOSECURITY, no auth headers, no text the user typed.input.rsalready redacts its trace line because it “used to print a password in full on every keystroke”, and an event bus is a much wider pipe than a trace line. - Subscription is a capability, per event family, granted like any other under ADR-003’s default deny.
2. Live effects, and honesty about which is which
flags.rs records something measured that inverts the obvious framing, and the
API must be built on it rather than around it:
Being re-read is a cost, not a capability. The engine fetches Roblox’s
settings document itself about two seconds in and applies it over the top, so
a DF* override of a key Roblox also sets is reverted to Roblox’s value while
the client is still starting.
So FFlag/FInt/FString are durable and take effect at the next launch.
DFFlag/DFInt/DFString are live and revocable, and govern the first
couple of seconds unless Roblox never sets the key. Dynamic is the weaker
guarantee, not the stronger one.
Capability::FlagsWrite and Capability::FlagsWriteDynamic already exist as
two capabilities. What has to follow:
- The prefix is validated and mismatches are errors. Writing
FFlagXthrough the live primitive must fail loudly. The engine will never re-read it, so it would report success and do nothing — the stub-that-lies AGENTS.md exists to forbid. - The result says when it took effect,
applied_noworapplies_next_launch, because that is the only thing a plugin needs in order to tell the user whether to relaunch. - Neither is named for speed. A plugin author who reads “dynamic” as “better” picks the one that silently stops working.
3. Contributed UI, over the engine’s canvas
This is the strongest justification of the three, because drawing a panel from declarative config is painful and drawing it from code is easy — and because it is the only one that gives the user something they cannot get any other way: a web view, a toast, or a plugin panel on top of Roblox, in one window. The mechanism is not a hook and not a second toplevel. GTK owns thexdg_toplevel (ADR-011) and the engine’s canvas is a wl_subsurface of it,
today placed above the parent, with
webview_dialog_opened/webview_dialog_closed lowering it for exactly as long
as a dialog is open. That is why a web view covers Roblox entirely instead of
sitting over it.
The intended arrangement inverts it: the engine’s subsurface sits permanently
below the parent, and the parent is transparent and input-transparent over the
canvas region. Then every GTK widget composites above the engine for free, and
a plugin panel is an ordinary widget rather than a special case.
Measured on 2026-08-23, and it works. All three questions were open when
this ADR was written; all three now have answers, taken under cage in a nested
compositor with grim for the composited screenshots and WAYLAND_DEBUG=1 for
the protocol.
- Transparency: yes, and it needs less than expected. A
window.background { background-color: transparent; }CSS provider on the toplevel is sufficient. - The engine shows through, and GTK’s opaque region was the whole obstacle.
With an opaque background GTK sends
set_opaque_regioncovering the entire surface, and a compositor is then entitled to skip painting anything beneath it — which is the real reason behind the module doc’s warning. With the CSS change GTK stops sendingset_opaque_regionat all, having worked out for itself that the surface is no longer opaque, and no manual override is needed. Three-way control:place_aboveshows Roblox;place_belowwithout the CSS shows a flat libadwaita background with the engine presenting frames nobody can see;place_belowwith the CSS shows the Roblox landing page with GTK’s header bar composited over it. - Input falls through, and only because the region is punched. GTK does
not shrink its input region when its content becomes transparent — it sends
set_input_regiononce for the whole surface plus the CSD shadow and never revisits it. An explicitset_input_regionexcluding the canvas rectangle works and is not clobbered. With it, two synthetic clicks inside the canvas produced fournativePassMouseButtoncalls into the engine; with the punch removed and nothing else changed, the same clicks produced zero.
What a plugin declares, and the escape hatch that is not a channel
A plugin never receives awl_surface, a GL context, or a handle to anything
Cordial composites. It declares content and Cordial draws it, which is ADR-007
applied to pixels rather than to sockets. The vocabulary is three things:
- A toast — text, a position, a duration. The server-location indicator is this and nothing more.
- A panel — the declarative widget set ADR-020 already defines for preferences, rendered somewhere other than the settings window.
- An image — the plugin renders offscreen and hands over a buffer.
- it cannot wedge the compositor, because the plugin never commits and never blocks — a slow plugin yields a stale frame, not a hung client;
- it is droppable, because Cordial owns the frame budget and can skip a plugin’s buffer while the engine is busy, which is impossible if the plugin holds the surface;
- it is bounded, because a size and a refresh cap are enforceable on a handover and unenforceable on somebody else’s surface.
.ROBLOSECURITY in the desktop secret service. Attribution and the chrome
exclusion are what make an overlay a feature rather than a phishing surface, and
they must be enforced by the compositing code rather than requested of the
plugin.
A compositor-level screenshot can now answer all three, which was impossible
until a4abe15: --headless runs the client under a wlroots compositor we
control, and wlr-screencopy will photograph the composited result. The
swapchain screenshot cannot — it shows the engine’s own output and looks
identical whether or not GTK is covering it.
What this does not change
No in-process code execution against the Roblox process. No hooking, no memory patching, no injected script environment. An event is something Cordial observed and chose to publish, not a callback the engine calls. A live flag is a value the engine re-reads of its own accord. A contributed panel is a Wayland surface Cordial composites. None of these is a primitive a fork could extract into a hook, which is the property ADR-001 and ADR-003 exist to preserve. Plugins still receive effects, not channels (ADR-007). A plugin subscribing toplace.joined does not get a socket; it gets a payload. A plugin drawing a
panel does not get a wl_surface; it declares content and Cordial draws it.
Default deny still holds (ADR-003). Every event family and every UI surface
is a capability, granted per profile, and a plugin that has been granted nothing
observes nothing — which is what a freshly installed plugin does today, and what
the Plugins page now names the profile for.