Defines the host<->guest wire contract for the .so -> wazero migration as a repo-local buf module (abi/proto/v1, generated Go in abi/v1): - manifest.proto: PluginManifest mirroring every static field of plugin.PluginRegistration (abi_version, deps, block/template registrations, admin pages, settings schema, theme presets, fonts, master pages, AI actions, RBAC method roles, CSS manifest, icon packs, directory extensions, job types, RAG fetcher types, settings panel, hook flags, core service bindings) - invoke.proto: bn_invoke envelope, Hook catalog (RENDER_BLOCK, RENDER_TEMPLATE, HANDLE_HTTP, JOB, LOAD, UNLOAD, RAG_FETCH, MEDIA_HOOK, DESCRIBE), AbiError, and job/lifecycle/rag/media/describe payloads - render.proto: RenderBlock/RenderTemplate payloads + RenderContext enumerating every ctx value from blocks/context.go (incl. BlockContext 1:1) - http.proto: buffered HttpRequest/HttpResponse for HANDLE_HTTP - db.proto: guest sql-driver messages (DbValue oneof over pgx-mappable types, query/exec/tx with host-side tx handles, SQLSTATE-carrying DbError) - capability.proto: HostCall envelope + one request/response pair per CoreServices interface method (content, settings+update, gating, crypto, menus, datasources, users, subscriptions, media.deposit, email.send, ai.text_call, ai.tools.register, bridge, jobs.submit, embeddings, rag, reviews, badges.refresh) The abi/ buf module is deliberately separate from the repo-root config: proto/ is the shared block/proto submodule (service API contracts); the ABI is SDK-internal and versions in lockstep with the guest shim. New `make abi` target runs buf lint + generate. docs/wasm-abi.md documents the ptr+len calling convention (bn_alloc, bn_invoke, packed u64), hook catalog, error semantics, abi_version evolution rules, and the full registration/ CoreServices coverage tables. Verified: `cd abi && buf lint` exit 0; `go build ./...` exit 0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
198 lines
12 KiB
Markdown
198 lines
12 KiB
Markdown
# Wasm Plugin ABI (v1)
|
|
|
|
The host↔guest wire contract for wazero-loaded BlockNinja plugins.
|
|
Schema: [`abi/proto/v1/`](../abi/proto/v1/) (buf module [`abi/`](../abi/)) —
|
|
generated Go: `git.dev.alexdunmow.com/block/core/abi/v1` (`abiv1`).
|
|
Design rationale: the wasm plugin migration design spec in the cms repo
|
|
(`docs/superpowers/specs/2026-07-03-wasm-plugin-migration-design.md`).
|
|
|
|
Regenerate with `make abi` (runs `buf lint` + `buf generate` in `abi/`).
|
|
The `abi/` buf module is deliberately separate from the repo-root buf config:
|
|
`proto/` is the shared block/proto git submodule (service API contracts),
|
|
while this ABI is SDK-internal and versions in lockstep with the guest shim,
|
|
so it lives repo-local.
|
|
|
|
## Versioning — `abi_version`
|
|
|
|
`PluginManifest.abi_version` (field 1, `manifest.proto`) carries the ABI
|
|
**major** version the plugin was built against. Current value: **1**.
|
|
|
|
- The host **rejects** any manifest whose major version it does not support —
|
|
at install/publish time (manifest read) and again at `DESCRIBE`
|
|
(`DescribeRequest.host_abi_version` tells the guest who is calling, so a
|
|
newer guest shim can refuse an older host symmetrically).
|
|
- Within a major version, evolution is protobuf-additive only: new fields,
|
|
new `Hook` values, new capability methods. Removing or renaming anything
|
|
wire-visible requires a major bump. `buf breaking` (FILE rules, configured
|
|
in `abi/buf.yaml`) enforces this against the previous commit.
|
|
|
|
## Calling convention (ptr+len, packed u64)
|
|
|
|
wasm exports can only pass `i32/i64/f32/f64`, so all payloads cross as
|
|
protobuf bytes in guest linear memory. The guest exports (via
|
|
`go:wasmexport`):
|
|
|
|
| Export | Signature | Purpose |
|
|
|---|---|---|
|
|
| `bn_alloc` | `(size: u32) → ptr: u32` | Host asks the guest to allocate `size` bytes in guest memory. The returned region stays valid until the current `bn_invoke` call returns. |
|
|
| `bn_invoke` | `(hook_id: u32, ptr: u32, len: u32) → packed: u64` | Host writes a serialized `InvokeRequest` at `(ptr, len)` (memory from `bn_alloc`) and calls with `hook_id = Hook` enum value (duplicated in the envelope for decode sanity). The return packs the response location: `packed = (ptr << 32) | len`, framing a serialized `InvokeResponse` in guest memory, valid until the next `bn_invoke` on this instance. |
|
|
|
|
Host functions (guest→host capability calls, module `env`) use the same
|
|
shape in reverse: the guest passes `(ptr, len)` framing a serialized
|
|
`HostCallRequest`; the host returns a packed `u64` framing a
|
|
`HostCallResponse` that it wrote into guest memory via `bn_alloc`.
|
|
|
|
A `packed` value of `0` means the callee could not even produce an envelope
|
|
(allocation failure / trap); the caller treats it as
|
|
`ABI_ERROR_CODE_INTERNAL` and discards the instance.
|
|
|
|
Instances are single-threaded: one `bn_invoke` at a time per instance;
|
|
concurrency comes from the per-plugin instance pool.
|
|
|
|
## Hook catalog (`invoke.proto`)
|
|
|
|
Host→guest calls. `InvokeRequest{hook, payload, deadline_ms}` →
|
|
`InvokeResponse{payload, error}`; `payload` holds the hook-specific message:
|
|
|
|
| Hook | Request / Response | Fires |
|
|
|---|---|---|
|
|
| `HOOK_RENDER_BLOCK` | `RenderBlockRequest` / `RenderBlockResponse` | Public render of one plugin block (`blocks.BlockFunc`). |
|
|
| `HOOK_RENDER_TEMPLATE` | `RenderTemplateRequest` / `RenderTemplateResponse` | Render of one plugin template (`templates.TemplateFunc`). |
|
|
| `HOOK_HANDLE_HTTP` | `HttpRequest` / `HttpResponse` | Buffered HTTP/ConnectRPC request forwarded to the guest's internal mux (only when `manifest.has_http_handler`). No streaming/SSE/WebSocket in v1. |
|
|
| `HOOK_JOB` | `JobRequest` / `JobResponse` | Background job dispatch for a `manifest.job_types` entry (`plugin.JobHandlerFunc`). |
|
|
| `HOOK_LOAD` | `LoadRequest` / `LoadResponse` | Plugin load (`PluginRegistration.Load`); `LoadRequest.host_config` delivers `AppURL`/`MediaPath`. |
|
|
| `HOOK_UNLOAD` | `UnloadRequest` / `UnloadResponse` | Plugin unload (`PluginRegistration.Unload`). |
|
|
| `HOOK_RAG_FETCH` | `RagFetchRequest` / `RagFetchResponse` | RAG re-index callback for a `manifest.rag_content_fetcher_types` entry (`plugin.ContentFetcher`). |
|
|
| `HOOK_MEDIA_HOOK` | `MediaHookRequest` / `MediaHookResponse` | Media lifecycle event (`plugin.MediaHooksProvider`), only when `manifest.has_media_hooks`. |
|
|
| `HOOK_DESCRIBE` | `DescribeRequest` / `DescribeResponse` | Publish-time manifest capture; the result is stored as `manifest.pb` in the `.bnp` artifact. Never called on a live instance. |
|
|
|
|
## Capability calls (`capability.proto`, `db.proto`)
|
|
|
|
Guest→host. Envelope: `HostCallRequest{method, payload}` →
|
|
`HostCallResponse{payload, error}`. `method` is `"<family>.<snake_method>"`;
|
|
each pair mirrors one Go interface method from `CoreServices` 1:1
|
|
(UUIDs as canonical strings, `map[string]any`/JSON as bytes):
|
|
|
|
| Family | Methods | Go surface |
|
|
|---|---|---|
|
|
| `content` | `get_author_profile`, `get_page`, `get_post`, `slugify`, `block_note_to_html`, `generate_excerpt`, `strip_html` | `content.Content` |
|
|
| `settings` | `get_site_settings`, `get_plugin_settings`, `update_site_setting` | `settings.Settings`, `settings.Updater` |
|
|
| `gating` | `get_subscriber_tier_level`, `evaluate_access` | `gating.Gating` |
|
|
| `crypto` | `encrypt_secret`, `decrypt_secret` | `crypto.Crypto` |
|
|
| `menus` | `get_menu_by_name`, `get_menu_items` | `menus.Menus` |
|
|
| `datasources` | `resolve_bucket`, `resolve_bucket_by_key` | `datasources.Datasources` |
|
|
| `users` | `get_by_username`, `get_by_id` | `auth.PublicUsers` |
|
|
| `subscriptions` | `get_user_tier_level`, `get_tier_by_slug`, `list_tiers`, `list_active_plans` | `subscriptions.Subscriptions` |
|
|
| `media` | `deposit` | `plugin.Media` |
|
|
| `email` | `send` | `plugin.EmailSender` |
|
|
| `ai` | `text_call`, `tools.register` | `CoreServices.AITextCall`, `ai.ToolRegistry` |
|
|
| `bridge` | `register_service`, `get_service` | `plugin.PluginBridge` |
|
|
| `jobs` | `submit` | `plugin.JobRunner` |
|
|
| `embeddings` | `generate_embedding`, `embed_content`, `is_available` | `plugin.EmbeddingService` |
|
|
| `rag` | `query`, `on_content_changed` | `plugin.RAGService` |
|
|
| `reviews` | `submit_review` | `plugin.ReviewSubmitter` |
|
|
| `badges` | `refresh_badges` | `plugin.BadgeRefresher` |
|
|
| `db` | `query`, `exec`, `tx_begin`, `tx_commit`, `tx_rollback` | `CoreServices.Pool` via the guest `database/sql` driver (`db.proto`) |
|
|
|
|
The guest half of the SDK implements the existing Go interfaces as stubs
|
|
marshaling to these calls, so plugin code compiles unchanged.
|
|
|
|
`CoreServices` members that do **not** cross as capability calls:
|
|
|
|
- `Pool` → the `db.*` driver messages (`db.proto`); the host executes under
|
|
the per-plugin Postgres role. `DbError.code` carries the SQLSTATE.
|
|
Transactions: `tx_begin` returns an opaque `tx_handle` (never 0); `query`/
|
|
`exec` with `tx_handle = 0` run autocommit. Handles die with the call
|
|
chain's deadline so a guest can never pin a connection.
|
|
- `Interceptors` (`connect.Option`) → host-side only; RBAC merges from
|
|
`manifest.rbac_method_roles`.
|
|
- `AppURL` / `MediaPath` → delivered once in `LoadRequest.host_config`.
|
|
- `CoreServiceBindings.Bind` → static `manifest.core_service_bindings`
|
|
declaration; the host constructs and mounts the handlers.
|
|
- `RAGService.RegisterContentFetcher` → static
|
|
`manifest.rag_content_fetcher_types` declaration + `HOOK_RAG_FETCH`
|
|
callback inversion.
|
|
|
|
## Error semantics
|
|
|
|
`AbiError{code, message}` travels in `InvokeResponse.error` and
|
|
`HostCallResponse.error`:
|
|
|
|
| Code | Meaning | Instance consequence |
|
|
|---|---|---|
|
|
| `ABI_ERROR_CODE_INTERNAL` | Handler ran and failed; `message` is the Go error text. | None (normal error). Guest traps / packed `0` returns are *treated as* INTERNAL by the host and **do** discard the instance. |
|
|
| `ABI_ERROR_CODE_DECODE` | Envelope or payload failed to decode. | Instance discarded (protocol desync). |
|
|
| `ABI_ERROR_CODE_UNIMPLEMENTED` | Callee does not implement the hook/capability (e.g. host too old for a new capability method). | None. |
|
|
| `ABI_ERROR_CODE_DEADLINE_EXCEEDED` | `deadline_ms` elapsed. | Instance considered poisoned, discarded. |
|
|
| `ABI_ERROR_CODE_PERMISSION_DENIED` | Caller not entitled to the capability. | None. |
|
|
|
|
Host-function errors surface to plugin code as ordinary Go errors via the
|
|
guest SDK. Repeated instance failures trip the existing
|
|
`PluginStatusFailed` path + admin notification. DB failures use `DbError`
|
|
(SQLSTATE-carrying) inside the `db.*` responses instead of `AbiError`, so
|
|
sqlc/pgx error handling keeps working.
|
|
|
|
## Manifest ↔ `PluginRegistration` mapping
|
|
|
|
`manifest.pb` (a serialized `PluginManifest`) is produced at publish time via
|
|
`HOOK_DESCRIBE` and read by the loader without instantiating the module.
|
|
Field-by-field:
|
|
|
|
| `PluginRegistration` field | Wire counterpart |
|
|
|---|---|
|
|
| `Name` | `PluginManifest.name` |
|
|
| `Version` | `PluginManifest.version` |
|
|
| `Dependencies` | `dependencies` (`Dependency`) |
|
|
| `Register` | Static effects captured by DESCRIBE: `blocks` (`BlockMeta`), `block_template_overrides`, `template_keys`, `system_templates`, `page_templates`, `email_wrapper_system_keys` |
|
|
| `RegisterWithProvisioner` | Same captures + `has_provisioner` (provisioning runs at load, host-side) |
|
|
| `Assets` | `.bnp` artifact `assets/` directory (host serves directly; never crosses the boundary) |
|
|
| `Schemas` | `.bnp` artifact `schemas/` directory (host loads into the block registry) |
|
|
| `SettingsSchema` | `settings_schema` (JSON bytes) |
|
|
| `ThemePresets` | `theme_presets` (JSON bytes) |
|
|
| `BundledFonts` | `bundled_fonts` (JSON bytes) |
|
|
| `MasterPages` | `master_pages` (`MasterPageDefinition`/`MasterPageBlock`) |
|
|
| `HTTPHandler` | `has_http_handler` + `HOOK_HANDLE_HTTP` |
|
|
| `SettingsPanel` | `settings_panel` |
|
|
| `AdminPages` | `admin_pages` (`AdminPage`) |
|
|
| `CSSManifest` | `css_manifest` (`CssManifest`) |
|
|
| `ServiceHandlers` | `rbac_method_roles` (method → role; the services themselves answer via `HOOK_HANDLE_HTTP`) + `core_service_bindings` |
|
|
| `JobHandlers` | `job_types` + `HOOK_JOB` |
|
|
| `AIActions` | `ai_actions` (`AiAction`) |
|
|
| `DirectoryExtensions` | `directory_extensions` (static fields + callback counts) |
|
|
| `MediaHooks` | `has_media_hooks` + `HOOK_MEDIA_HOOK` |
|
|
| `Load` | `has_load_hook` + `HOOK_LOAD` |
|
|
| `Unload` | `has_unload_hook` + `HOOK_UNLOAD` |
|
|
| `Migrations` | `.bnp` artifact `migrations/` directory (Goose runs host-side; never crosses) |
|
|
| `RequiredIconPacks` | `required_icon_packs` |
|
|
|
|
## Render context
|
|
|
|
`RenderContext` (`render.proto`) is the explicit envelope of every value
|
|
blocks read from `ctx` today (`core/blocks/context.go`): request info,
|
|
`BlockContext` (the pongo2 data struct, 1:1), current page/post/author/
|
|
category/master-page, requested path, injected/expected slots, editor flag,
|
|
block + page IDs, human-proof banner, detail row, theme variables JSON, and
|
|
locale.
|
|
|
|
Function-valued context entries cannot serialize; their v1 mapping:
|
|
|
|
- `GetQueries` → the `db.*` driver (plugin sqlc code, per-plugin role).
|
|
- `SlotRenderer` / `MediaResolver` / `EmbedResolver` → **open items** (below).
|
|
|
|
## Open items (flagged for runtime WOs — additive, no major bump needed)
|
|
|
|
- **AI tool execution**: `AiToolRegisterRequest` registers a tool; executing
|
|
its guest-side `Handler` needs a host→guest hook (e.g. `HOOK_AI_TOOL_CALL`).
|
|
Deliberately not added here — the WO fixes the v1 hook catalog.
|
|
- **Job progress**: `plugin.JobHandlerFunc`'s `progress(current, total,
|
|
message)` callback needs a `jobs.progress` host function.
|
|
- **Bridge calls**: `bridge.register_service`/`get_service` cover
|
|
registration/lookup only; typed cross-plugin *invocation* (today: shared
|
|
in-process Go values) needs a runtime design.
|
|
- **Directory extension callbacks**: `panel_section_count` /
|
|
`pin_decorator_count` declare the guest callbacks; invoking them needs a
|
|
hook.
|
|
- **Slot/media/embed resolvers in render**: container-slot rendering and
|
|
media/embed resolution during a guest render need host functions (or host-
|
|
side pre-rendering into `RenderContext`).
|