docs(abi): capability-surface matrix + wasm-abi.md updated for WO-WZ-019

abi-capability-surface.md is the canonical plugin-need → ABI-mechanism map
(manifest / host_call / hook), with WZ-020 rows marked. wasm-abi.md: content
family drift fixed (list_posts), new families and hooks documented, open
items closed except slot/media/embed render resolvers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Alex Dunmow 2026-07-04 10:28:44 +08:00
parent b6e9fdd531
commit 925b6051a3
2 changed files with 132 additions and 20 deletions

View File

@ -0,0 +1,99 @@
# ABI Capability Surface (WO-WZ-019)
The canonical map of **every capability a plugin needs from the CMS** to its
ABI mechanism. The rule is *injection, not reliance*: a plugin obtains 100% of
its host interactions by (a) declaring static intent in `manifest.pb`, (b)
calling a `blockninja.host_call` capability method, or (c) implementing a
host-invoked hook. Nothing requires linking the Go `core` module — the Go
guest SDK (`plugin/wasmguest`) is an ergonomic front-end over these
primitives, not part of the model. A plugin in any language that speaks
protobuf over the [wasm ABI](wasm-abi.md) reaches everything below.
Legend — **Mechanism**: `manifest` (static declaration, consumed by the host
loader), `host_call` (dynamic guest→host capability), `hook` (host-invoked
guest logic), `artifact` (a directory in the `.bnp` the host consumes).
**Codeless equivalent**: how a plugin with NO `plugin.wasm` gets the same
effect (the WO-WZ-020 declarative path; rows marked *WZ-020* land there).
## Static contributions (what the plugin IS)
| Capability | Mechanism | Status | Codeless equivalent |
|---|---|---|---|
| Register block types (key/title/category/icon/schema) | `manifest.blocks` (`BlockMeta`) + `schemas/` artifact | ABI-native | Same — pure declaration, zero code |
| Block template overrides | `manifest.block_template_overrides` | ABI-native | Same |
| Templates / system templates / page templates | `manifest.template_keys`, `system_templates`, `page_templates` | ABI-native | Same + `.ninjatpl` files in the artifact (WZ-020) |
| Master pages | `manifest.master_pages` | ABI-native | Same |
| Email wrappers | `manifest.email_wrapper_system_keys` | ABI-native | Same |
| Template tags / filters (computing) | `manifest.declared_tags` / `declared_filters` + `HOOK_RENDER_TAG` / `HOOK_APPLY_FILTER` | ABI-native | N/A — a *computing* tag requires code by definition |
| Template tags (pure snippet/partial, no logic) | today: same as computing (hook) | **WZ-020**: declare as a template partial, host-rendered, no hook | Partial file in `templates/` |
| Powered block with a *declared data provider* (no render hook at all) | today: `HOOK_RENDER_BLOCK` returning `RenderBlockResponse.powered` | **WZ-020**: manifest provider declaration (`posts`, `pages`, `menu`, `datasource:<id>`, …) + `.ninjatpl`; host fetches + renders | The WZ-020 headline feature |
| Settings schema / settings panel / admin pages | `manifest.settings_schema`, `settings_panel`, `admin_pages` | ABI-native | Same |
| Theme presets / bundled fonts / CSS manifest / icon packs | `manifest.theme_presets`, `bundled_fonts`, `css_manifest`, `required_icon_packs` | ABI-native (JSON payloads; no Go types on the wire) | Same. Fonts are **purely declarative** — the host consumes `bundled_fonts` at load; runtime *activation* (choosing a font) is a site setting, writable via `settings.update_site_setting`. |
| AI actions | `manifest.ai_actions` | ABI-native | Same |
| RBAC method roles / core service bindings | `manifest.rbac_method_roles`, `core_service_bindings` | ABI-native | Same (bindings host-mounted) |
| Job types / RAG fetcher types / directory extensions (static fields) | `manifest.job_types`, `rag_content_fetcher_types`, `directory_extensions` | ABI-native | Job/RAG types need their hooks → code |
| Migrations / assets / MF web bundle | `artifact` `migrations/`, `assets/`, `web/` (host-run/served) | ABI-native | Same |
| Persistent data dir | `plugin.mod data_dir``manifest.data_dir` | ABI-native | N/A (no code to need it) |
## Dynamic requests (host_call families)
Every family below is registered in the cms host (`plugin/wasmhost/caps`) and
mirrored 1:1 by a guest SDK stub. Wire types are language-neutral: canonical
UUID strings, JSON bytes for maps, protobuf for structure.
| Capability | Methods | Status |
|---|---|---|
| Content reads | `content.get_author_profile`, `get_page`, `get_post`, `list_posts`, `slugify`, `block_note_to_html`, `generate_excerpt`, `strip_html` | ABI-native |
| **Content authoring** (WZ-019) | `content.create_page`, `set_page_blocks`, `publish_page`, `set_page_seo`, `upsert_post` | ABI-native |
| **Provisioning** (WZ-019) — idempotent seed/ensure, **Load-time** | `provisioner.ensure_data_table`, `merge_site_settings`, `ensure_setting`, `ensure_page`, `override_site_settings`, `ensure_menu_item`, `register_embedding_config`, `ensure_embed`, `ensure_job_schedule`, `update_data_table_row_field`, `disable_orphaned_job_schedules`, `ensure_plugin`, `ensure_custom_color`, `ensure_media` | ABI-native. `RegisterWithProvisioner` is **inert under wasm** (DESCRIBE stubs host functions): call `deps.Provisioner` from `Load` instead. `EnsureEmbed` crosses template-rendered embeds only — `RenderFunc` cannot serialize. |
| Settings | `settings.get_site_settings`, `get_plugin_settings`, `update_site_setting`, **`update_plugin_settings`** (WZ-019; host-pinned to the caller's own plugin) | ABI-native |
| Menus (read) | `menus.get_menu_by_name`, `get_menu_items` | ABI-native. Menu *writes* are seed-shaped → `provisioner.ensure_menu_item`. |
| Media | `media.deposit` (full pipeline, deterministic-ID + `created` flag, folder, alt text, stable `media:<uuid>` ref) + `provisioner.ensure_media` (immutable seed semantics) | ABI-native |
| Gating / crypto / datasources / users / subscriptions | as in [wasm-abi.md](wasm-abi.md) §Capability calls | ABI-native |
| Email | `email.send` | ABI-native |
| AI | `ai.text_call`, `ai.tools.register` (+ execution via `HOOK_AI_TOOL_CALL`) | ABI-native |
| Jobs | `jobs.submit`, **`jobs.progress`** (WZ-019; correlated to the running job via the HOOK_JOB call context — no job ID on the wire) | ABI-native |
| Bridge | `bridge.register_service`, `get_service` (availability), **`bridge.invoke`** (WZ-019; opaque-payload cross-plugin calls → provider's `BridgeInvokable` or `HOOK_BRIDGE_CALL`) | ABI-native. Typed in-process Go values still don't cross — by design; `GetService` stays nil for wasm consumers. |
| Embeddings / RAG / reviews / badges | as in wasm-abi.md | ABI-native |
| Database | `db.query/exec/tx_*` under the per-plugin Postgres role | ABI-native |
## Host-invoked hooks (logic the plugin injects)
| Hook | Contract | Status |
|---|---|---|
| `HOOK_DESCRIBE` | `DescribeRequest/Response` — manifest capture at publish | ABI-native |
| `HOOK_LOAD` / `HOOK_UNLOAD` | `LoadRequest` (host config) / `UnloadRequest` | ABI-native |
| `HOOK_RENDER_BLOCK` | html OR powered `{template, data_json}` | ABI-native |
| `HOOK_RENDER_TEMPLATE` | `RenderTemplateRequest/Response` | ABI-native |
| `HOOK_RENDER_TAG` / `HOOK_APPLY_FILTER` | declared-tag/filter callbacks, re-entrancy-free | ABI-native |
| `HOOK_HANDLE_HTTP` | buffered HTTP + trusted identity headers | ABI-native |
| `HOOK_JOB` | `JobRequest/Response` + `jobs.progress` | ABI-native |
| `HOOK_RAG_FETCH` | content re-index callback | ABI-native |
| `HOOK_MEDIA_HOOK` | media lifecycle events | ABI-native |
| **`HOOK_AI_TOOL_CALL`** (WZ-019) | `AiToolCallRequest{slug, params_json}``{content, error_message}`; executes the guest handler recorded at `ai.tools.register` | ABI-native |
| **`HOOK_BRIDGE_CALL`** (WZ-019) | `BridgeCallRequest{service_name, method, payload}``{payload}`; provider side of `bridge.invoke` | ABI-native |
| **`HOOK_DIRECTORY_PANEL_SECTION`** / **`HOOK_DIRECTORY_PIN_DECORATOR`** (WZ-019) | indexed callbacks over `manifest.directory_extensions` counts; pin decorators return the mutated pin | ABI-native |
**Per-instance rule for callback hooks:** anything the host may call back on
an arbitrary pooled instance (AI tools, bridge services, RAG fetchers) must be
registered in `Register` — which runs on every instance — not in `Load`
(one instance only). Use `wasmguest.HostServices()` for deps at Register time.
## Remaining open items
- **Slot / media / embed resolvers during a guest render**`RenderContext`
still cannot carry them (function values). The powered-block path sidesteps
this: host-side rendering has the real resolvers. Only a plain-HTML guest
block that wants to resolve `media:` refs itself is affected. Tracked for a
render-focused WO; not blocking any shipping plugin.
## Conformance
- Guest↔host wire parity: every capability method has a golden
request/response pair (`plugin/wasmguest/caps/testdata/golden/`, generated
by the guest stubs) that the cms host replays byte-for-byte through its real
handlers (`cms plugin/wasmhost/caps` `TestGoldenParity`). The count check
fails if a registered method lacks a golden.
- Injection-not-reliance: `TestRawProtobufConformance` (cms caps package)
drives representative capabilities with hand-constructed protobuf messages
through the `HostCallRequest` envelope — no guest SDK in the path.

View File

@ -179,6 +179,11 @@ init lazily and only on instances that serve those hooks. A block render
(`HOOK_RENDER_BLOCK`) receives **no** services — only `ctx` + content — so a
DB-backed block cannot get its pool from Load.
The same rule governs the WO-WZ-019 callback hooks: AI tool handlers
(`ai.tools.register`) and bridge service values (`Bridge.RegisterService`)
are recorded per instance, and `HOOK_AI_TOOL_CALL` / `HOOK_BRIDGE_CALL` may
land on ANY pooled instance — so register them in `Register`, not `Load`.
The escape hatch is `wasmguest.HostServices()`: it returns the `CoreServices`
bound at `_initialize` on **every** instance (live `db.*` `Pool` + capability
stubs). A DB-backed block registers its pool from `HostServices().Pool` inside
@ -240,6 +245,10 @@ Host→guest calls. `InvokeRequest{hook, payload, deadline_ms}` →
| `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. |
| `HOOK_AI_TOOL_CALL` | `AiToolCallRequest` / `AiToolCallResponse` | Execute the guest handler of a tool registered via `ai.tools.register`. Register tools in `Register` (every pooled instance runs it), not `Load`. |
| `HOOK_BRIDGE_CALL` | `BridgeCallRequest` / `BridgeCallResponse` | Provider side of `bridge.invoke`: run a method on one of THIS plugin's registered bridge services (`plugin.BridgeInvokable`). Register services in `Register`. |
| `HOOK_DIRECTORY_PANEL_SECTION` | `DirectoryPanelSectionRequest` / `DirectoryPanelSectionResponse` | Render the index-th `DirectoryExtensions.PanelSections` callback. |
| `HOOK_DIRECTORY_PIN_DECORATOR` | `DirectoryPinDecoratorRequest` / `DirectoryPinDecoratorResponse` | Run the index-th pin decorator; the mutated pin map is returned. |
## Capability calls (`capability.proto`, `db.proto`)
@ -250,8 +259,8 @@ each pair mirrors one Go interface method from `CoreServices` 1:1
| 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` |
| `content` | `get_author_profile`, `get_page`, `get_post`, `list_posts`, `slugify`, `block_note_to_html`, `generate_excerpt`, `strip_html` — plus the WO-WZ-019 writes: `create_page`, `set_page_blocks`, `publish_page`, `set_page_seo`, `upsert_post` | `content.Content`, `content.Author` |
| `settings` | `get_site_settings`, `get_plugin_settings`, `update_site_setting`, `update_plugin_settings` (host-pinned to the caller's own plugin) | `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` |
@ -261,8 +270,9 @@ each pair mirrors one Go interface method from `CoreServices` 1:1
| `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` |
| `bridge` | `register_service`, `get_service`, `invoke` (opaque-payload cross-plugin calls; provider answers via `HOOK_BRIDGE_CALL` or an in-process `plugin.BridgeInvokable`) | `plugin.PluginBridge` |
| `jobs` | `submit`, `progress` (correlated to the running job via the HOOK_JOB call context) | `plugin.JobRunner` + `JobHandlerFunc`'s progress callback |
| `provisioner` | `ensure_data_table`, `merge_site_settings`, `ensure_setting`, `ensure_page`, `override_site_settings`, `ensure_menu_item`, `register_embedding_config`, `ensure_embed`, `ensure_job_schedule`, `update_data_table_row_field`, `disable_orphaned_job_schedules`, `ensure_plugin`, `ensure_custom_color`, `ensure_media`**Load-time**: call `deps.Provisioner` from `Load`; `RegisterWithProvisioner` is inert under wasm (DESCRIBE stubs host functions). `EnsureEmbed` rejects `RenderFunc`-only embeds (functions cannot cross). | `plugin.Provisioner` |
| `embeddings` | `generate_embedding`, `embed_content`, `is_available` | `plugin.EmbeddingService` |
| `rag` | `query`, `on_content_changed` | `plugin.RAGService` |
| `reviews` | `submit_review` | `plugin.ReviewSubmitter` |
@ -282,7 +292,9 @@ No silent gaps: each member is either a guest stub or served host-side.
| Member | Disposition |
|---|---|
| `Content` (7 methods) | **stub**`caps/content.go` |
| `Content` (8 methods) | **stub**`caps/content.go` |
| `ContentAuthor` (5 write methods, WO-WZ-019) | **stub**`caps/author.go` |
| `Provisioner` (14 methods, WO-WZ-019) | **stub**`caps/provisioner.go`; Load-time only (see the `provisioner` family row) |
| `Settings` / `SettingsUpdater` | **stub**`caps/settings.go` (one value, both fields) |
| `Gating` | **stub**`caps/gating.go`; `EvaluateAccess` crosses but falls back to the pure `gating.EvaluateAccess` on transport error |
| `Crypto` | **stub**`caps/crypto.go` |
@ -291,9 +303,9 @@ No silent gaps: each member is either a guest stub or served host-side.
| `PublicUsers` | **stub**`caps/users.go` |
| `Subscriptions` | **stub**`caps/subscriptions.go` |
| `Media` | **stub**`caps/media.go` |
| `ToolRegistry` + `AITextCall` | **stub**`caps/ai.go` (`ai.tools.register` + `ai.text_call`; tool `Handler` stays guest-side) |
| `ToolRegistry` + `AITextCall` | **stub**`caps/ai.go` (`ai.tools.register` + `ai.text_call`); the tool `Handler` stays guest-side, recorded per instance and executed via `HOOK_AI_TOOL_CALL` — register tools in `Register`, not `Load` |
| `EmailSender` | **stub**`caps/email.go` |
| `Bridge` | **stub**`caps/bridge.go`; `RegisterService` forwards names only (value dropped), `GetService` reports availability but returns `nil` (a typed value cannot cross — open item) |
| `Bridge` | **stub**`caps/bridge.go`; `RegisterService` forwards the name AND records the value locally for `HOOK_BRIDGE_CALL` dispatch; `GetService` reports availability but returns `nil` (a typed value cannot cross — by design); cross-plugin calls use `Invoke` (`bridge.invoke`, opaque payloads) |
| `ReviewSubmitter` | **stub**`caps/reviews.go` |
| `BadgeRefresher` | **stub**`caps/badges.go` |
| `JobRunner` | **stub**`caps/jobs.go` |
@ -571,19 +583,20 @@ The core half (this SDK) defines the wire + guest dispatch. The cms host must:
> wasm world. This SDK ships the mechanism only; per-plugin migration (e.g.
> assumechaos) is a later phase.
## Open items (flagged for runtime WOs — additive, no major bump needed)
## Open items
WO-WZ-019 closed the original set: AI tool execution (`HOOK_AI_TOOL_CALL`),
job progress (`jobs.progress`), cross-plugin bridge invocation
(`bridge.invoke` + `HOOK_BRIDGE_CALL` + `plugin.BridgeInvokable`), directory
extension callbacks (`HOOK_DIRECTORY_PANEL_SECTION` /
`HOOK_DIRECTORY_PIN_DECORATOR`), plugin provisioning (the `provisioner.*`
family), content authoring (`content.*` writes), and the plugin-own settings
write. The full capability→mechanism map lives in
[abi-capability-surface.md](abi-capability-surface.md).
Still open:
- **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`).
side pre-rendering into `RenderContext`). The powered-block path sidesteps
this — host-side rendering has the real resolvers.