ADR-0004: WASM Plugin System — API Contract and Host Function ABI¶
- Status: Accepted (normative — implementation tasks build against this)
- Date: 2026-09-17
- Deciders: architect (API owner), backend-dev, frontend-dev, QA
- Supersedes: none
- Related:
docs/advanced/plugins.md(user-facing guide), crates/pkm-plugin, epic E3
Context¶
Stratum's pkm-plugin crate provides a WASM (wasmtime) plugin runtime, but the
host functions are stubs (note_read returns a fake note, note_write always
succeeds, http_request is blocked), the crate is not linked into src-tauri,
and no UI or vault-level loading exists. Backend, frontend, and QA each need a
single authoritative contract to build against. Any drift between the API spec
and the running code invalidates one or more downstream tasks.
This ADR fixes the contract. It is the normative reference; the implementation
task (t_b197920d) must make the runtime match it exactly, and the integration
task (t_e132158a) must expose it through the documented Tauri command surface.
Decision¶
-
Runtime + ABI. Plugins run as WebAssembly in wasmtime.
wasm32-unknown-unknowncore modules (WASI-free) and genuinewasm32-wasip1modules both instantiate: the linker registers a real WASI preview1 context (sostdwasm32-wasip1 builds link), in addition to the"pkm"host module. Host functions are imported from the"pkm"module in WASM linear-memory via the ABI in../../advanced/plugins.md("Host Function ABI" section). Every host call reads its request from WASM memory at the guest-supplied offset and writes its response back at offset 0 of the plugin's linear memory, returning the byte length of the response. This convention is what the current runtime (crates/pkm-plugin/src/runtime.rs) already implements for its imports; it is preserved. No filesystem is preopened for WASI — vault I/O is scoped to thepkm.*host API. -
Host functions. Exactly four host imports exist:
pkm.log,pkm.note_read,pkm.note_write,pkm.http_request. There are no other host functions in the public contract.logrequires no permission (always allowed). -
WASM module type of every host function:
(param i32 i32) (result i32)— a pointer to a UTF-8 byte span, its length, and a response byte length. The implementations cast host-side results into this shape; the guest never sees an ABI surface beyond(i32, i32) -> i32. -
Envelope. All host request/response payloads are JSON strings, bounded at
HOST_PAYLOAD_MAX(1 MiB) for both directions. Responses use the 1:1 envelope defined in the API spec: every host callback returns aHostResponse(success or error shape); thehttp_requesthost returns aHttpResponseEnvelopewith a nestedHostErrorfor transport-level failures. -
Error codes & mapping. The canonical machine-readable error set is
PluginErrorCode(PluginDenied,PluginNotFound,PluginLoadError,PluginRuntimeError,NoteNotFound,NoteWriteFailed,HttpTransport,HttpTimeout,HttpSsid,HttpStatus,InvalidArgument,ConfigError,Internal). Rust crate errors map onto it per the "Error Mapping" section of the spec. HTTP status 400..=599 from a network call is an error surfaced to the plugin asPluginErrorCode::HttpStatuswith the HTTPS response body embedded. -
Permissions. Plugins declare capabilities in their embedded manifest. The permission grammar is exactly the one already implemented in
crates/pkm-plugin/src/permissions.rs:file:read,file:write,network,git,exec,all. A host call is gated by the permission bound in the API spec table; a missing grant aborts the host callback and surfacesPluginDeniedto the plugin.networkis required byhttp_request,file:readbynote_read,file:writebynote_write. -
SSRF guard.
http_requestis constrained to the vault'snetwork.allowlist(frompkm_core::Config). Connections to any allow-listed host, or to a loopback/private address, are allowed; other targets returnHttpSsid. Timeout isHTTP_TIMEOUT= 10 s (non-tunable in the contract).note_read/note_writeare scoped by an explicit vault-root check that mirrorsresolve_safe_path/resolve_safe_write_pathinsrc-tauri/src/commands/page.rs: note paths are vault-relative, canonicalized, and rejected on traversal. -
Plugin packaging. A plugin is shipped as a single file: a
plugin.wasmcore module with an embedded JSON manifest in the custom section namedstratum:manifest. The legacy sidecar convention (<entry>.wasm.manifest.jsonnext to the.wasm) remains supported for reading (both inPluginRegistry::load_pluginand in vault directory scans), but embedded manifests take precedence and are the canonical format. ThePluginConfigstruct inpkm_coreremains the on-disk enablement/override mechanism; it must not be the source of the manifest schema. -
Plugin lifecycle. States are
Discovered → Loading → Ready | Failed → (Enable/Disable), always externally visible through the documented Tauri command surface; a failed load recordsPluginLoadErrorand never returns a partial plugin. Hooks are dispatched per the hook table (onSave,onOpen,onLink,onSearch) plus arbitraryon*names. The runtime instantiates the module with the"pkm"host imports plus the real WASI preview1 context (see spec §7.1); no filesystem is preopened. -
Tauri command surface. The five lifecycle commands and two host-test commands in the API spec (
list,enable,disable,reload,status,note_read,http_request) are the only plugin commands the frontend should call. Their names, argument names, and DTO shapes are normative; the frontend task must use exactly these. The host fuzzing commandplugin_http_requestdrives the samehttp_requestbackend used by plugins, so a plugin author can verify allowlist/timeout behavior without writing a plugin.
Consequences¶
- Backend (
t_b197920d,t_e132158a) implements against this contract; no later contract change is permitted inside E3 without a new ADR. - Frontend (
t_ec520ca3) builds the management UI against the named Tauri commands and DTO schemas; it may mock locally before integration lands. - QA acceptance ("no stub behavior remains", "errors are visible/logged") maps directly to the error tables in the API spec.
- Contract stability is a release-blocking criterion: the
HostFunctionenum and the four import names are ABI-frozen for the v0.7.x series.
Status of this ADR¶
Implemented and verified as of commit e1af45f (epic E3): the host functions
are real (see crates/pkm-plugin/src/host.rs), the crate is linked into and
wired through src-tauri (PluginManager, Tauri plugin commands, vault
startup scan), the management UI exists, and the contract is exercised
end-to-end by crates/pkm-tests/tests/plugin_e2e.rs. The Context section
above describes the state at decision time and is retained for the record.
References¶
- API specification:
docs/advanced/plugins.md(normative). - Existing (stub) runtime:
crates/pkm-plugin/src/runtime.rs. - Existing permission module:
crates/pkm-plugin/src/permissions.rs. - Existing registry:
crates/pkm-plugin/src/registry.rs. - Path-safety reference implementations:
src-tauri/src/commands/page.rs(resolve_safe_path,resolve_safe_write_path).