The plugin UI contribution surface: a bounded subset of the chroix display list, not a host-call API
Источник:
dev/fera-research/docs/oc4/idea-03-plugin-ui-display-list.md(read-only; сгенерировано scripts/sync-docs.mjs)
The plugin UI contribution surface: a bounded subset of the chroix display list, not a host-call API
Section titled “The plugin UI contribution surface: a bounded subset of the chroix display list, not a host-call API”- kind: idea
- area: plugins / ui / trust
- evidence:
~/dev/fera/project-design/context/ADR.md:54-56— ADR-002: third-party WASM plugins are “Capability-gated: read-only by default, write/action capabilities (send messages, mutate UI, network access) require explicit per-capability opt-in”~/dev/fera/project-design/context/RFC.md:168-171— same three, re-stated~/dev/fera/project-design/context/NAMING-UX.md:198— the grants UI should show “read messages? send messages? mutate UI?”~/dev/fera/project-design/context/ADR.md:892-901— ADR-022: capability names are host-defined, stable identifiers; the rights schema is “frozen from the first stable version and changes additively only: new capabilities are added, existing ones never change meaning; unknown capabilities are must-understand”~/dev/fera/project-design/context/TECHSPEC.md:326-339— thewitsketch has exactly two interfaces (plugin-readonly,plugin-write); no UI interface exists~/dev/fera/chroix-project/CHROIX-DISPLAY-LIST.md§0 — “one contract, two backends”; plain data, no engine types; buffer not structs; damage first-class; serializable, diffable, replayable; zero per-frame alloc~/dev/fera/chroix-project/CHROIX-DISPLAY-LIST.md§4 — 13 ops, 1-byte opcode + 3-byte LE length, skippable/unknown-op-safe~/dev/fera/chroix-project/CHROIX-DISPLAY-LIST.md§3 — versioned envelope:magic,header_size,abi_version,frame_id, section offsets, append-only~/dev/fera/chroix-project/CHROIX-DISPLAY-LIST.md§6 — “who-shapes rule”: text shaping is the framework’s job, not the backend’s; atlas key owned by the framework~/dev/fera/chroix-contract/src/validate.rs— header validation already exists as a pure function over bytes~/dev/fera/chroix-project/UI-FRAMEWORK-IDEAS-V2.md§11 — dev tooling: tree inspector, deterministic replay~/dev/fera/chroix-project/UI-FRAMEWORK-IDEAS-V2.md§13 — “contract crate for everything crossing [the DLL boundary]”~/dev/fera/chroix/TODO.mdgap G1 — the wire is currently not self-contained (side-table offsets hardcoded0)
Суть / Thesis
Section titled “Суть / Thesis”“mutate UI” is one of exactly three named plugin capabilities in the entire
design corpus. It appears three times — ADR-002:55, RFC.md:168,
NAMING-UX.md:198 — and is never specified. There is no wit interface for
it, no semantics, no vocabulary, no threat model, no statement of what a
plugin is allowed to draw or where.
Meanwhile ADR-022 makes the capability set a load-bearing, effectively
permanent artifact: capability IDs are host-defined and must-understand, the
schema is frozen at first stable release and only ever extended additively. So
the decision about whether mutate-ui exists and what it means has a
deadline that arrives long before the plugin system is usable — you cannot
additively change what an existing capability means later.
That makes this an idea with a real clock on it, and the answer is already sitting in the repo.
The chroix display list is precisely the shape of a safe plugin UI contribution, and it exists already.
It is (by its own spec, §0): plain data with no engine types, a versioned envelope, a bounded op vocabulary of 13 codes, damage regions as first-class data, unknown-op-skippable framing, and — by design — no brush table, no glyph atlas handle, no texture handles to steal. A plugin’s UI contribution becomes a byte buffer the host splices into the frame’s op stream inside a slot rect. Sandboxing stops being a policy problem and becomes a parsing problem, which the project already knows how to solve.
Варианты / Options (with tradeoffs)
Section titled “Варианты / Options (with tradeoffs)”A. Host-call API — the obvious default. Do nothing special.
plugin-ui interface exposes push_clip, fill_rect, draw_glyphs, …
each a wasmtime host function.
- Pros: trivially obvious, plugin authors get fine-grained control.
- Cons: (1) a host call per draw op — the exact cost ADR-005/OQ-024 spend the entire architecture eliminating; a busy plugin UI at 24 fps would dominate the frame. (2) Every op needs a permission check at call time, which ADR/TECHSPEC explicitly reject as the model: “unauthorized capabilities should be absent from the callable surface, not merely policy-checked at call time” (TECHSPEC.md:337-339). Per-op policy checks are the model the project already argued itself out of. (3) Grants a plugin the raw brush/atlas/transform machinery, which is where the interesting escapes live.
B. Hand plugins a Vello Scene. (the shortcut, if chroix’s contract is skipped)
- Pros: no new format; Vello is already a real scene graph.
- Cons: violates chroix-contract §0 outright (“no engine types”) and makes the sandbox wasmtime’s problem rather than a byte-format’s. Pins the plugin UI surface to one backend, defeating the Engine-axis swap (ADR-009/028).
C. Bounded display-list subset in a slot (proposed).
The mutate-ui capability links exactly one host function:
submit-view: func(slot: u32, frame: list<u8>) -> result<_, view-rejected>;The list<u8> is a chroix display-list buffer for a plugin-chosen edition.
The host:
- validates the envelope (
header_size,abi_version, offsets — the function that already exists inchroix-contract/src/validate.rs); - decodes only the ops that edition’s profile allows;
- copies the ops into the frame arena (capped, host-owned — plugin memory is never aliased);
- splices them into the host’s op stream under the plugin’s slot rect, with the plugin’s damage rects unioned into the frame damage list.
The plugin gets no brush table, no glyph atlas handle, no image blob pool,
no transform pool — brushes resolve against the host’s compiled theme
(theme.rkv, ADR-030) and glyphs against the host’s font set, which §6’s
who-shapes rule already makes the framework’s job. A plugin drawing text gets
text for free, correctly shaped, with the host’s cache key.
Proposed solution
Section titled “Proposed solution”1. Define the capability vocabulary as a profile, not a boolean
Section titled “1. Define the capability vocabulary as a profile, not a boolean”mutate-ui should not be one bit. Three profiles, each strictly nested,
declared at the wit interface level so an ungranted profile is genuinely
absent from the instance:
| Profile | Plugin may | Needs |
|---|---|---|
ui-decorate |
paint into a slot it is given: fills, images, glyph runs | slot id |
ui-compose |
build and lay out its own subtree inside the slot, re-emit per frame | slot rect + own layout |
ui-panel |
own a floating panel/overlay slot (z-order band), receive input routed to it | slot + focus + input events |
This matters because of the freezing rule: a single mutate-ui boolean
forces every future distinction (can this plugin make a panel? can it take
focus? can it animate?) to become either an undeclared behaviour or an
incompatible redefinition. Three nested profiles are additively extendable,
which is the only growth direction ADR-022 permits.
2. Make the frame buffer the entire attack surface — deliberately
Section titled “2. Make the frame buffer the entire attack surface — deliberately”What a plugin can actually do is bounded by what bytes mean:
- Bounded length — the submitted buffer is copied into a host arena with a hard cap; wasmtime memory is not aliased, so a plugin cannot mutate a frame after validation, and cannot make the host allocate without limit.
- Bounded reach — the splice is clipped to the slot rect. Clip escape is a clip-op validation concern, already inside the display list’s contract.
- Bounded vocabulary — 13 opcodes. Unknown codes are skipped by the
length-prefixed framing (
CHROIX-DISPLAY-LIST.md§4) rather than treated as fatal, which is what makes edition skew safe instead of dangerous. - No handles to steal — brushes, fonts, and images all resolve to host-owned tables. A plugin cannot name a brush index that means something else, because it never supplies brushes.
- The failure mode is “bad pixels in a rectangle”, not “arbitrary host behavior.” That is a class of bug the host can survive, log, and quarantine.
3. Let the existing dev tooling cover plugins for free
Section titled “3. Let the existing dev tooling cover plugins for free”Chroix §11 wants a tree inspector. Because plugin UI is display-list data, one inspector handles native and plugin trees identically — no second tool, no “debug mode that is only available when plugins are off” (which would be both a security hole and a second code path). This is a real, currently-unspecified dividend of choosing (C) over (A)/(B).
4. Authoring ergonomics: ship a macro, not a manual encoder
Section titled “4. Authoring ergonomics: ship a macro, not a manual encoder”Plugin authors should never hand-write opcodes. Export the view! /
select!-style macro subset (V2 §20.1) in the plugins-sdk against the same
contract types, so authoring a plugin panel is the same authoring experience
as authoring native UI — with the compile-time template + dirty-mask codegen
already designed. The plugin emits a display list from its own compiled graph;
the host splices. One authoring model, two trust levels.
5. Honest gaps and what must be true first
Section titled “5. Honest gaps and what must be true first”This idea has dependencies that are honestly not met yet, and the proposal should say so rather than hide behind them:
- chroix gap G1 blocks it. Today the wire is not self-contained —
chroix-contract/src/encode.rs:43-48hardcodes every side-table offset to0. A plugin-submitted buffer with a real brush table cannot be validated against the current encoder. So: G1 (and theTODO.mdStage-2 header growth 56→72 bytes) is a prerequisite, not a parallel task. That is fine — it is already on the plan — but it means this idea is a consumer of the Stage-2 work, and should be sequenced after it. - Edition/profile naming is the actual deliverable, and it is a naming
decision. The semantics above are the easy half. The half that cannot be
retrofitted is the identifier spelling in the frozen rights schema, and
that is a one-afternoon decision that should be made now, recorded, and
left alone. Recommendation: name the top-level capability
ui.contribute(it covers decorate/compose/panel without promising the others), and express the profiles as scopes under it — so a futureui.host-windoworui.replace-chat-viewis a new scope, not a redefinition ofui.contribute. - What this deliberately gives up, so it is a decision and not a surprise: custom shaders, custom blend modes beyond the four §11 already fixed, plugins that reflow native UI around themselves, and plugins that own window chrome. All four are correct to give up at v1; none can be added later within this vocabulary, which is exactly why the vocabulary should be chosen before it is frozen rather than after.
Why not defer?
Section titled “Why not defer?”The deferral argument (“no plugin will want to draw UI until the plugin system works”) is precisely inverted here: ADR-022’s freeze rule means the cost of getting this wrong is permanent, and the cost of deciding it early is one design note. It is the rare decision in this project where being early is almost free and being late is unrecoverable.