Track HiveGrid Cloud planning reference

This commit is contained in:
david committed 2026-06-26 20:48:01 +02:00
1 parent b3d5ea6764
commit e341c5938a
5784 files changed
+1104929 -3

No files matched your search

@@ -0,0 +1,83 @@
# Capability Matrix
Every host function exposed to a plugin sits behind a capability. The
manifest must declare a capability before the runtime binds the
corresponding host functions; calling an undeclared host-fn throws
`CapabilityDeniedError` and audits as `outcome=denied`.
## Closed enum
The runtime maintains a closed enum of capability names in
`packages/plugin-runtime/src/capabilities.ts`. Anything outside this
list is rejected at manifest parse time.
| Capability | Risk | Host binding availability | Unlocks / intended binding | Scope shape |
| ---------------- | -------- | ------------------------- | --------------------------------------------------------------- | ----------------------------------------- |
| `log` | none | available | `ctx.log.{info,warn,error}` - structured logging into host pino | none |
| `audit` | none | available | `ctx.audit.record({ action, target?, meta? })` | none |
| `tenant.info` | low | reserved / fail-closed | Reserved tenant metadata binding; not bound today | none |
| `secret.read` | medium | reserved / fail-closed | Reserved per-tenant secret reads; not bound today | `{ keyPattern: "^slack-" }` planned |
| `storage.read` | medium | reserved / fail-closed | Reserved tenant-scoped key/value reads; not bound today | per-prefix locking planned |
| `storage.write` | medium | reserved / fail-closed | Reserved tenant-scoped key/value writes; not bound today | per-prefix locking planned |
| `http.outbound` | high | reserved / fail-closed | Reserved outbound fetch binding; not bound today | `{ allowedHosts: ["hooks.slack.com"] }` |
| `db.read` | high | reserved / fail-closed | Reserved DB binding for marketplace work; not bound today | `{ tablePrefix: "tx_" }` planned |
| `db.write` | critical | reserved / fail-closed | Reserved DB binding for marketplace work; not bound today | `{ tablePrefix: "tx_" }` planned |
`PLUGIN_CAPABILITY_NAMES` is intentionally broader than the production host
binding set. As of this foundation, `PLUGIN_AVAILABLE_HOST_CAPABILITY_NAMES`
contains only `log` and `audit`; declaring higher-risk capabilities parses, but
production install/grant/enable paths reject them until their bindings, tests,
and security review ship.
## Risk legend
- **none** — no tenant data, no outbound — safe for any plugin.
- **low** — tenant identity only; no payload exposure.
- **medium** — touches per-tenant data inside the host; operator
review recommended.
- **high** — issues network requests OR reads tenant business data;
audit-trail required and operator must explicitly approve at install.
- **critical** — mutates tenant business data; install requires
out-of-band operator sign-off; auto-disable on the slightest
anomaly.
## Common pitfalls
- **Declaring `http.outbound` without `allowedHosts`** is the most
common authoring mistake. The runtime denies every request unless
the host appears in the allowlist. The lint command warns on this
shape.
- **Assuming `db.read` is bound today** — the DB capability is
reserved-only in the foundation. A plugin that declares it parses
fine but the host returns `no_host_implementation` on call.
- **Using `audit` as a payload queue** — see the async-task example;
the audit body has a cap. Pass identifiers, not blobs.
## How to request a new capability
1. Propose the name (lowercase, dot-separated).
2. Write a one-paragraph threat model: what data can leak, what
privileges are gained, what reverse-direction trust does the
capability imply?
3. Add to `PLUGIN_CAPABILITY_NAMES` in `capabilities.ts`.
4. Add the host-fn implementation in `capability-gateway.ts` via the
provided `hostFns` map; never bypass the gateway.
5. Update this table.
6. Operator-side: external security review when the new capability
ships in production (Phase 06 #23 sign-off — see audit-docs.md).
## Audit trail mapping
Every successful or failed call to a host-fn produces one row in
`PluginCapabilityUse`:
| Column | Meaning |
| ------------------- | ---------------------------------------------------- |
| `capability` | Capability name (e.g. `http.outbound`) |
| `outcome` | `success` / `denied` / `error` |
| `payloadHash` | SHA-256 of stable call descriptor; never the body |
| `durationMs` | Time spent in the host-fn (excluding gateway frame) |
| `errorCode` | Short code on `denied` / `error` (e.g. `not_declared_in_manifest`) |
Tenant admins see this list under
`/app/:tenant/settings/plugins/:id/audit`.
@@ -0,0 +1,152 @@
# Plugin CLI Reference
`toolctrl-cli plugins ...` — the author + operator command surface
for the plugin pipeline. Operator subcommands manage installed-plugin
rows; author subcommands scaffold / build / sign / lint / publish.
```bash
toolctrl-cli plugins --help
```
## Author subcommands
### `scaffold`
```bash
toolctrl-cli plugins scaffold \
--name slack-notifier \
--publisher de.acme \
--description "Send tenant events to Slack" \
[--target-dir ./my-plugins] [--dry-run]
```
Generates a new plugin skeleton under `<targetDir>/<name>/`:
```
slack-notifier/
├── plugin.toml # manifest seed (placeholder sig/hash)
├── src/index.ts # HookHandler example
├── package.json # @toolctrl/plugin-sdk devDependency
├── tsconfig.json # ES2022 strict
└── README.md # next-step checklist
```
`--dry-run` prints the file list without writing anything.
### `build`
```bash
toolctrl-cli plugins build [--cwd ./slack-notifier] [--output plugin.wasm]
```
Detects the project's wasm toolchain (AssemblyScript via `asconfig.json`,
TinyGo via `go.mod`, Rust via `Cargo.toml`) and prints the canonical
build command. Does NOT auto-execute — pipe the command into your CI or
run it yourself. Exits non-zero when no toolchain marker is found.
### `sign`
```bash
toolctrl-cli plugins sign \
--wasm plugin.wasm \
--manifest plugin.json \
--key ./keys/publisher.priv \
[--key-id staging-2026-05] \
[--out-sig plugin.wasm.sig]
```
1. SHA-256-hashes the wasm.
2. Builds the same canonical manifest projection used by the runtime verifier
(sorted-keys JSON minus `signature`/`signedBy`/`wasmSha256`).
3. Signs `<canonical>:<wasmSha256>` with the ed25519 private key.
4. Writes the raw signature hex to `<wasm>.sig` (or `--out-sig`).
5. Prints the `wasmSha256` / `signature` / `signedBy` lines to splice
back into the manifest.
The private key is read from disk and never echoed. `--key-id` is
logged only — useful for operators to track which signing key produced
which artifact.
### `lint`
```bash
toolctrl-cli plugins lint --manifest plugin.json
```
Runs the host Zod schema, then layers author checks on top:
- `manifest_invalid` (error) — Zod rejected the manifest.
- `unknown_capability` (error) — capability not in the host registry.
- `invalid_host` (error) — `allowedHosts` entry is not a hostname.
- `unsigned_placeholder` (warn) — signature/signedBy still zeros.
- `unsigned_wasm` (warn) — wasmSha256 still zeros.
- `http_outbound_unrestricted` (warn) — `http.outbound` declared but
`allowedHosts` is empty.
- `high_risk_capability` (warn) — `db.write` declared.
Exit codes:
| Code | Meaning |
| ---- | -------------------- |
| 0 | Clean |
| 1 | At least one error |
| 2 | Warnings only |
### `publish`
```bash
toolctrl-cli plugins publish \
--wasm plugin.wasm \
--manifest plugin.json \
[--signature plugin.wasm.sig] \
[--out ./dist]
```
Packs `plugin.wasm` + `plugin.json` (+ `plugin.wasm.sig` if present)
into `<pluginId>-<version>.tar.gz` ready for operator upload.
**Local-only — no HTTP push.** Operators must inspect what they
upload; auto-uploading would hide bad signatures from review.
## Operator subcommands
| Command | Action |
| ---------------------------- | -------------------------------------------------------------- |
| `plugins list` | List installed plugin rows (filter by tenantId + enabled state) |
| `plugins enable <tenantId> <pluginId>` | Enable an installed plugin |
| `plugins disable <tenantId> <pluginId>` | Disable an installed plugin |
| `plugins uninstall <tenantId> <pluginId>` | Operator-only deletion of the tenant Plugin row (cascades user configs) |
| `plugins verify-audit-chain <tenantId> [--pluginId <pluginId>] [--from <iso>] [--to <iso>] [--json]` | Verify the tenant-wide PluginCapabilityUse hash chain; `--pluginId` reports matching rows without slicing the chain |
These operate against the host database directly; they are intended
for incident-response and don't substitute the tenant-admin UI. Uninstall is
deliberately available only through this operator CLI path until plugin-owned
data cleanup/down-migration semantics are reviewed.
Operator lifecycle commands use the same production gates as the service path:
production enables require `PLUGIN_RUNTIME_PRODUCTION_ENABLED=true`, reject
`PLUGIN_REQUIRE_CAPABILITY_CONSENT=false`, reject capabilities whose host
bindings are not production-reviewed, check current grants before enabling, and
write lifecycle audit events transactionally with the mutation. Disable remains
available as the emergency containment path.
Permission split: tenant routes and UI use `app.plugins.use` for installed
plugin reads, per-user non-secret settings, and settings/audit surfaces.
`app.plugins.manage` is still required for tenant API/UI install,
grant/revoke, enable/disable, and future update flows. CLI commands are operator
escape hatches against the host database and do not replace the tenant
permission model; CLI uninstall is not exposed as a tenant permissioned
API/UI flow.
## Round-trip smoke
```bash
toolctrl-cli plugins scaffold --name smoke --publisher de.smoke --dry-run
toolctrl-cli plugins build --cwd ./smoke
# (run the printed build command in your wasm toolchain)
toolctrl-cli plugins lint --manifest ./smoke/plugin.json
toolctrl-cli plugins sign --wasm ./smoke/plugin.wasm --manifest ./smoke/plugin.json --key ./keys/dev.priv
toolctrl-cli plugins publish --wasm ./smoke/plugin.wasm --manifest ./smoke/plugin.json
```
The output is the file you upload through the tenant-admin plugins
settings page.
@@ -0,0 +1,148 @@
# Plugin Manifest Reference
> The plugin manifest is the contract between an installable WASM
> binary and the host runtime. The host parses the manifest with the
> Zod schema in `packages/plugin-runtime/src/manifest.ts` and rejects
> anything that does not validate — adding a field here without
> updating the schema does nothing.
The manifest may be TOML or JSON (whichever your toolchain emits). The
runtime works against the parsed object, not the source bytes — except
during signing, where the canonical form is the sorted-keys JSON
projection (see `cli-reference.md#sign`).
## Top-level fields
| Field | Type | Required | Notes |
| --------------- | --------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `schemaVersion` | `1` | yes | Bumped on breaking schema changes. Host rejects unknown versions. |
| `id` | reverse-DNS string | yes | Pattern `^[a-z]+(\.[a-z0-9-]+){1,}$`. Globally unique across hosts. |
| `version` | semver | yes | `MAJOR.MINOR.PATCH[-prerelease]`. Host enforces SemVer for upgrades. |
| `name` | string ≤ 256 chars | yes | Display name. |
| `description` | string ≤ 2048 chars | no | Free-form for the marketplace. |
| `entry` | string ≤ 256 chars | yes | Path of the wasm relative to the manifest, e.g. `plugin.wasm`. |
| `wasmSha256` | 64-char hex | yes | SHA-256 of the binary at signing time. Re-checked on every load. |
| `signature` | 128-char hex | yes | Ed25519 signature over `canonicalManifest:wasmSha256`. |
| `signedBy` | 64-char hex | yes | Raw 32-byte ed25519 public key (hex). Registered with operator. |
| `allowedHosts` | array of public hostnames | no | Outbound HTTP allowlist for `http.outbound`. Empty disables HTTP. No scheme, port, localhost, IP literal, `.local`, or `.internal`. |
| `capabilities` | array of capability entries | no | Capability declarations. See below. |
| `hooks` | array of hook entries (≥1) | yes | At least one hook must be declared; a zero-hook plugin never runs. |
| `ui.settings.entry` | relative bundle path | no | Optional settings HTML entry rendered by the host in a sandboxed `PluginSettingsFrame`. Absolute paths, URL schemes, and `..` segments are rejected. When present, the settings entry must also declare `ui.settings.sha256` or `ui.settings.integrity` so the host can verify the bundled HTML before rendering it. |
Settings HTML communicates with the host through the versioned
`plugin.settings.*` `postMessage` protocol documented in
[`sdk-reference.md#settings-iframe-protocol`](sdk-reference.md#settings-iframe-protocol).
## Capability entries
```toml
[[capabilities]]
name = "http.outbound"
scope = { allowedHosts = ["hooks.slack.com"] }
[[capabilities]]
name = "secret.read"
scope = { keyPattern = "slack-webhook-.*" }
```
Each entry has:
- `name` — capability name from the closed enum (see
`capability-matrix.md`).
- `scope` — optional, capability-specific narrowing. Permitted shape:
| Field | Allowed for | Meaning |
| -------------- | --------------------- | --------------------------------------------- |
| `allowedHosts` | `http.outbound` | Hostname allowlist (no scheme, no port) |
| `keyPattern` | `secret.read` | Regex (1-128 chars) the key must match |
| `tablePrefix` | `db.read`, `db.write` | Required prefix; pattern `^[a-z][a-z0-9_]*_$` |
Other keys are rejected by `.strict()`.
## Hook entries
```toml
[[hooks]]
name = "entity.created"
export = "handleEntityCreated"
```
- `name` — host hook event name; must match an entry the host emits.
- `export` — function name exported from the wasm binary that handles
the hook.
Both fields cap at 128 characters.
### Foundation Hook Payloads
The SDK exports matching payload types from `@toolctrl/plugin-sdk`
(`TypedHookHandler<"entity.updated">`, `PluginHookWorkflowExecutedPayload`,
and friends). Runtime validation is strict, so handlers should rely on
these field names instead of ad-hoc shapes.
| Hook | Payload summary |
| --- | --- |
| `tenant.created` | `tenantId`, `slug`, optional `ownerEmail`, optional `plan`, `createdAt` |
| `user.created` | `userId`, optional nullable `tenantId`, `email`, optional `locale`, `createdAt` |
| `entity.created` / `entity.updated` / `entity.deleted` | `entityType`, `entityId`, nullable `tenantId`, optional `userId`, optional `changedFields`, `occurredAt` |
| `workflow.created` / `workflow.updated` / `workflow.deleted` | nullable `tenantId`, `workflowId`, nullable `userId`, `status`, `appliesToAllTenants`, optional `changedFields`, `occurredAt`; workflow blocks, credentials, and raw input are intentionally not included. |
| `workflow.executed` | `tenantId`, `workflowId`, `workflowExecutionId`, optional nullable `userId`, `type`, `status`, `durationMs`, `errorCode` (`null` for `success`, required for `error`), `appliesToAllTenants`, `occurredAt`; raw execution input, output, and error bodies are intentionally not included. |
| `workflow.failed` | `tenantId`, `workflowId`, `workflowExecutionId`, optional nullable `userId`, `type`, fixed `status: "error"`, required `errorCode`, `durationMs`, `appliesToAllTenants`, `occurredAt`; raw execution input, output, and error bodies are intentionally not included. |
## Strictness
The schema uses Zod's `.strict()` everywhere — unknown fields cause
parse failure. This is intentional: extending the manifest is a
deliberate, host-side operation, never a plugin-author hack.
## Canonical bytes (for signing)
The CLI and runtime verifier both sign and verify
`<canonicalManifestJSON>:<wasmSha256>` where the canonical form is the manifest
minus `signature`, `signedBy`, and `wasmSha256`, projected to JSON with deeply
sorted keys (see `canonicalPluginSignaturePayload` in
`packages/plugin-runtime/src/signature.ts`).
This means adding / removing capabilities, hooks, or any other field
invalidates the signature — re-signing is required for every authoring
change.
## Example (TOML)
```toml
schemaVersion = 1
id = "de.acme.slack-notifier"
version = "1.2.0"
name = "Slack Notifier"
description = "Sends tenant events to a Slack channel."
entry = "plugin.wasm"
wasmSha256 = "a1b2..."
signature = "c3d4..."
signedBy = "e5f6..."
allowedHosts = ["hooks.slack.com"]
[[capabilities]]
name = "log"
[[capabilities]]
name = "secret.read"
scope = { keyPattern = "slack-webhook-.*" }
[[capabilities]]
name = "http.outbound"
scope = { allowedHosts = ["hooks.slack.com"] }
[[hooks]]
name = "entity.created"
export = "handleEntityCreated"
[[hooks]]
name = "entity.updated"
export = "handleEntityUpdated"
```
## Versioning + compatibility
Plugins may carry a `minHostVersion` / `maxHostVersion` constraint
(see `versioning.md`). Install rejects manifests whose declared host-version
range does not include the current host version.
@@ -0,0 +1,210 @@
# Plugin SDK Reference
> `@toolctrl/plugin-sdk` — the typed surface plugin authors compile
> against. Mirrors the runtime capability set so an SDK upgrade is a
> straight version bump on the author side.
## Installing
The SDK lives in the host monorepo today as `workspace:*`. External
plugin authors install it as a regular npm dependency:
```bash
pnpm add -D @toolctrl/plugin-sdk
```
## Top-level exports
| Export | Kind | Purpose |
| ----------------------------------------- | ----- | --------------------------------------------------------------------------- |
| `defineHook` | fn | Identity wrapper that captures `Input`/`Output` types at the call site |
| `createPluginSettingsClient` | fn | Settings iframe helper around the host `postMessage` protocol |
| `HookHandler` | type | `(input, ctx) => Output \| Promise<Output>` |
| `PluginContext` | type | Per-invocation context (logger, capabilities, tenant info) |
| `HostApi` | type | Aggregate of optional bindings; only the declared ones are bound |
| `PluginCapabilityName` | type | Closed enum of capabilities |
| `PluginLogger` | type | Pino-subset (info/warn/error) |
| `PluginHttpClient` | type | `http.outbound` capability surface |
| `PluginStorage` | type | `storage.read` / `storage.write` capability surface |
| `PluginAudit` | type | `audit` capability surface; production-available but still capability-gated |
| `PluginTenantInfo` | type | Read-only tenant metadata (`tenant.info`) |
| `PluginSettingsClient` | type | Settings iframe helper returned by `createPluginSettingsClient` |
| `PluginSettingsClientError` | class | Host-declared settings request failure |
| `PluginSettingsClientTimeoutError` | class | Settings request timeout failure |
| `PluginSettingsFrameMessage` | type | Settings iframe `postMessage` request union |
| `PluginSettingsFrameResultMessage` | type | Host response union for settings iframe requests |
| `PluginSettingValue` | type | JSON-safe value shape accepted by per-user plugin settings |
| `PLUGIN_SETTINGS_FRAME_PROTOCOL_VERSION` | const | Current settings iframe protocol version (`1`) |
| `PLUGIN_SETTINGS_FRAME_CHANNEL_META_NAME` | const | Meta tag name that carries the host-generated channel id |
## Authoring shape
```ts
import { defineHook, type HookHandler } from "@toolctrl/plugin-sdk";
export const handleHello: HookHandler<{ name: string }, { greeting: string }> = defineHook((input, ctx) => {
ctx.log.info({ name: input.name }, "hello");
return { greeting: `Hello, ${input.name}!` };
});
```
Every hook export resolves to a single WebAssembly function with a
`(arg) -> result` shape. The runtime serialises both directions as
JSON; complex types fall through the standard `JSON.stringify` lens.
## Payload ABI
The foundation runtime exposes the active invocation payload as UTF-8
JSON through two raw WebAssembly imports:
| Import | Signature | Purpose |
| --------------------------- | --------------------- | ------------------------------------------------- |
| `env.toolctrl_payload_len` | `() -> i32` | Length in bytes of the JSON payload |
| `env.toolctrl_payload_byte` | `(index: i32) -> i32` | Byte value at `index`, or `0` outside the payload |
The SDK exports `PLUGIN_PAYLOAD_ABI_VERSION` and
`PLUGIN_PAYLOAD_ABI_IMPORTS` so toolchain bindings can assert the host
contract. Higher-level AssemblyScript/Rust helpers are still follow-up
work; raw WASM plugins can already decode the JSON bytes themselves.
## Settings iframe protocol
When a manifest declares `ui.settings.entry`, the host renders that HTML
inside a sandboxed `PluginSettingsFrame`. The iframe has an opaque
origin (`sandbox="allow-scripts"`, no `allow-same-origin`) and a deny-by-default
CSP, so settings UIs communicate with the host only through the versioned
`postMessage` protocol exported by the SDK.
Use `createPluginSettingsClient()` to read the injected channel id, send
`ready`, `get`, and `set` messages to `window.parent`, and accept responses
only when the message came from `window.parent` and matches the expected
protocol version, plugin id, channel id, and request id:
```ts
import { createPluginSettingsClient, PluginSettingsClientError, PluginSettingsClientTimeoutError } from "@toolctrl/plugin-sdk";
const settings = createPluginSettingsClient({
pluginId: "com.acme.settings",
});
settings.ready();
try {
const theme = await settings.get("theme");
applyTheme(theme);
await settings.set("theme", "dark");
} catch (error) {
if (error instanceof PluginSettingsClientError) {
console.warn("setting request failed", error.code, error.message);
} else if (error instanceof PluginSettingsClientTimeoutError) {
console.warn("setting request timed out", error.message);
} else {
throw error;
}
}
```
The host validates `pluginId`, `channelId`, `requestId`, keys, and values.
Values must be JSON-safe (`PluginSettingValue`) and stay within the host
size/depth budgets. Persistent writes are CSRF-protected, tenant/user scoped,
and rate-limited by the route action.
## Per-capability bindings on `ctx`
Each capability bound to the invocation appears as an optional field on
`ctx`. The runtime sets a field only when the manifest declared it AND
the gateway has a host implementation for it. Always null-check before
use:
```ts
if (!ctx.http) {
ctx.log.warn({}, "http capability not bound — manifest must declare http.outbound");
return { ok: false };
}
await ctx.http.fetch({ url: "https://example.com", method: "GET" });
```
This shape gives you compile-time documentation: every capability the
plugin uses is visible at the top of the handler.
## Logger
`ctx.log` is a structured logger. The host writes the message to its
own logging pipeline (pino under the hood). The signature mirrors pino:
```ts
ctx.log.info({ tenantId, recordId }, "entity created");
ctx.log.warn({ retries: 3 }, "retrying");
ctx.log.error({ err: errSerializable }, "fatal");
```
Never log secrets — output sanitisation is best-effort, not a guarantee.
## HTTP client (`ctx.http`)
Reserved SDK shape for `http.outbound`. It is not bound in the current
production/foundation runtime; manifests may declare it only as a reviewed
future capability, and calls fail closed until the host binding is explicitly
enabled after security review.
```ts
const response = await ctx.http!.fetch({
url: "https://hooks.slack.com/services/T0/B0/abc",
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: "hello" }),
});
```
When enabled, the host validates the URL against `allowedHosts` from the manifest's
`http.outbound` scope before issuing the request. Any other host
results in a denied audit row.
## Storage (`ctx.storage`)
Reserved SDK shape for `storage.read` and `storage.write`. It is not bound in
the current production/foundation runtime; tenant-scoped storage remains
fail-closed until the capability receives a dedicated security review.
```ts
const channel = await ctx.storage!.get(`slack:${ctx.tenantId}:default-channel`);
await ctx.storage!.put(`slack:${ctx.tenantId}:default-channel`, "#alerts", {
ttlSeconds: 86_400,
});
```
Keys are byte-strings (no length cap today, but treat 256B as a soft
budget). Values are byte-strings; encode JSON yourself if you need
structured data.
## Audit (`ctx.audit`)
Production-available when the plugin manifest declares `audit` and the tenant
has granted that capability. Records a tenant-visible audit event:
```ts
await ctx.audit.record({
action: "slack.notification-sent",
target: `entity:${recordId}`,
meta: { channel: "#alerts" },
});
```
Use for state-change observability. Do not abuse as a queue —
metadata payloads have a hard cap.
## Errors
Throwing from a handler aborts the invocation. The runtime captures the
exception, records `outcome=error` against every in-flight capability,
flushes the audit batch, and marks the invocation `failed`. The plugin
crash-tracker auto-disables a plugin after repeated failures.
## Versioning
The SDK follows SemVer. Major bumps may add or remove capability
surfaces; consult `docs/plugins/versioning.md` before pinning to a new
major. The runtime advertises its host range through `minHostVersion` /
`maxHostVersion` in the manifest — when in doubt, lint your manifest
against the running host.
+135
View File
@@ -0,0 +1,135 @@
# Plugin Versioning + Compatibility Policy
The plugin runtime supports three independent version axes. This
document defines the rules each axis follows and how they compose into
the compatibility checks the host enforces at install time.
## Axes
| Axis | Scope | Policy |
| ----------------------- | ----------------------------------------------------- | ----------------------------------------------- |
| Manifest schema version | `schemaVersion` field on every plugin.toml | Integer-bumped on breaking schema changes |
| Plugin SDK version | `@toolctrl/plugin-sdk` npm package | NPM SemVer |
| Host runtime version | The host binary running the plugin | NPM SemVer (toolctrl-boilerplate `package.json`) |
Each plugin pins:
```toml
schemaVersion = 1 # required
minHostVersion = "1.0.0" # optional, inclusive lower host bound
maxHostVersion = "1.9.9" # optional, inclusive upper host bound
```
## SemVer policy for plugin authors
| Change | Bump |
| ----------------------------------------------------- | ------------- |
| Add a new hook subscription | minor |
| Drop a hook subscription | major |
| Add a new declared capability | minor |
| Tighten an `allowedHosts` / `keyPattern` scope | major |
| Loosen an `allowedHosts` / `keyPattern` scope | minor |
| Change `entry` filename | major |
| Logic-only change inside the wasm | patch |
| Re-sign with a new key (`signedBy` change) | minor (audit) |
Side-by-side updates are not implemented in the foundation. The
`Plugin` table has a unique `(tenantId, pluginId)` constraint, and
`installPlugin` rejects a duplicate `pluginId` for the tenant with
`already_installed`. To move a tenant to a new plugin version today,
operators must disable/remove the existing install path first, then
install the replacement.
## SemVer policy for the host
| Change | Bump |
| ----------------------------------------------------- | ------------- |
| Add a capability to the closed enum | minor |
| Remove a capability from the closed enum | major |
| Add a new optional manifest field | minor |
| Make an optional manifest field required | major |
| Bump `schemaVersion` (breaking manifest change) | major |
| Reduce plugin fuel limit | major |
| Increase plugin fuel limit | minor |
| Rework hook payload shape (breaking) | major |
## Compatibility matrix template (reserved)
Reserved for a future boot-time compatibility sweep. The foundation
does not evaluate an installed plugin set against this table on worker
boot. Current enforcement is per install: the manifest must parse, the
`schemaVersion` must be accepted, and optional `minHostVersion` /
`maxHostVersion` constraints must include the running host version.
| Host version | Manifest `schemaVersion` accepted | Plugin SDK majors known to work | Status |
| ------------ | --------------------------------- | ------------------------------- | ---------- |
| `1.x` | `1` | `0.x` | current |
| `2.x` | `1`, `2` | `0.x`, `1.x` | planned |
When a host crosses a major boundary it must:
1. Bump the rejected `schemaVersion` set in the runtime.
2. Update this table.
3. Send a deprecation banner to every plugin author 2 majors in
advance (the marketplace surface owns the comms — backlog).
## Install-time check
`PluginLifecycleService.installPlugin` validates:
1. Manifest parses with the current Zod schema.
2. `schemaVersion` is in the host's accepted set.
3. Production runtime operations are explicitly enabled in production
with `PLUGIN_RUNTIME_PRODUCTION_ENABLED=true`.
4. Optional `minHostVersion` / `maxHostVersion` include the running
host version. Bounds are inclusive; omitting both means "any host".
5. `signaturePublicKey` matches `manifest.signedBy`.
6. `manifest.signedBy` is present in `PLUGIN_TRUSTED_PUBLIC_KEYS`.
7. The pre-positioned disk binary verifies against the submitted
`wasmSha256`, manifest signature, manifest signing key, and trusted
key set.
8. The submitted `wasmSha256` matches `manifest.wasmSha256`.
9. No plugin with the same `(tenantId, pluginId)` is already installed.
10. The tenant has not exceeded `PLUGIN_MAX_INSTALLS_PER_TENANT`.
Foundation: any of these failing rejects the install with a
`PluginLifecycleError`.
## Boot-time check (reserved)
The boot-time compatibility matrix remains reserved. If implemented,
worker boot would:
1. Read every installed `Plugin` row.
2. Compare against the running host version.
3. Mark out-of-range plugins `incompatible_host_version` and skip them
on hook fan-out until the operator updates the host or the plugin.
The foundation does not enforce this yet; plugins remain `enabled`
across host upgrades. Host-version constraints are enforced when a
plugin is installed, not during worker boot.
## Upgrade guidance for plugin authors
- **Don't ship a patch that changes capability declarations.** Patches
must be wasm-only; manifest changes are at least a minor.
- **Re-sign on every release** — the signature covers `wasmSha256` so
any binary change requires re-signing anyway.
- **Test against the host's matching SDK major** before publishing.
The host SDK ships in the same monorepo and is pinned to the host
release.
## Deviations from SemVer
Two cases break strict SemVer:
1. **Security re-signs**: changing `signedBy` (e.g. publisher key
rotation) bumps minor + records an audit-event flag so operators
notice. The change is non-functional, but operators must re-trust
the new key.
2. **Capability-scope tightening**: tightening an `allowedHosts`
list is a *user-visible* break (some calls now deny) even though
the runtime API is identical. Major bump required.
Both are tracked through the audit-log so a tenant admin can correlate
upgrades with denial-row spikes.