91 KiB
API Plugin Foundation — Design
Status: Draft for review
Date: 2026-05-13
Owner: Suaveplan core
Scope: Foundation packages under packages/api/ for a plug-and-play, multi-tenant, hybrid manifest+code plugin system.
1. Goal
Deliver the foundation layer that lets any future API capability (billing, users, search, files, …) ship as an independently installable, hot-reloadable, versioned plugin against a stable host contract.
Foundational decisions:
| Decision | Choice |
|---|---|
| Tenant model | Multi-tenant with row-level isolation |
| Plugin shape | Hybrid: declarative manifest (plugin.json) + code (definePlugin({...})) |
| Database requirement | Optional per plugin |
| Hot reload | Blue-green by default (zero-downtime); 503-window mode opt-in for deployments that can't host two concurrent module instances |
| Reload safety | Automatic rollback to last-known-good module on failed start; RollbackJournal retains the prior 3 known-good specifiers + manifests + migration ledger snapshots per plugin |
| Versioning | Host semver + capability flags (granular feature opt-in) |
| Plugin packaging | Workspace + npm-resolvable, same loader contract |
| HTTP framework binding | Framework-agnostic abstract router with adapter packages |
| Database access shape | Engine-neutral by default, engine-specific escape via capability check |
| Tenant ID source | Pluggable resolver chain; JWT-claim resolver default |
| Isolation tiers | Three first-class tiers: trusted (same process), isolated (worker thread per plugin, full capability marshalling), sandboxed (worker + permission-gated host proxies, optional WASM target). Trust level declared in manifest, gated by host config. |
| Distributed transactions | Two engines ship in foundation: api-saga (orchestrator, event-bus-backed, compensable steps) and api-2pc (two-phase-commit coordinator for engines that support PREPARE TRANSACTION) |
| GraphQL | First-class engine alongside REST. Plugins can mount GraphQL types/resolvers; host federates schemas across plugins; subscriptions over WebSocket |
| Persistent connections | First-class WebSocket and SSE engines with subscription bus, tenant-scoped fan-out, multi-host broadcast (Redis adapter), reconnect tokens |
| Auth ecosystem | Foundation ships api-auth-jwt, api-auth-anonymous, api-auth-bearer-static, and api-auth-better-auth together so the auth contracts are exercised against a real third-party library from day one |
| Package strategy | Layered foundation packages + reference plugin + scaffold |
The spec is designed to land as a single coherent foundation. Items are sequenced into eight implementation stages (Section 15) to keep the dependency graph linear and the testing surface manageable, but every stage is part of the v1 design contract — not a "v2 maybe."
2. Architecture
2.1 Package family (packages/api/)
Layered, downward dep direction. Nothing below depends on api-host.
# ── Contract / core packages ──────────────────────────────────────────────
api-types # Contract types only. Zero runtime, zero deps.
api-manifest # Zod schema + validate + dep/capability resolution. → api-types, error, zod
api-router # Abstract Router/Middleware/Request/Response + composition + blue-green slot mgmt. → api-types, error
api-loader # Resolve plugin module → import() → reload + RollbackJournal. → api-manifest, error, events
api-lifecycle # Lifecycle FSM (incl. crashed + rolled_back states). → fsm, error, events
api-host # Thin orchestration that wires loader+lifecycle+router+manifest. → all above, logger, opentelemetry
# ── Worker isolation & sandboxing ─────────────────────────────────────────
api-worker-rpc # Message protocol over MessagePort. Request/response correlation,
# AbortSignal forwarding, backpressure, error reconstruction.
api-worker-host # Worker-side runtime; hosts a single plugin, accepts capability proxies.
api-router-worker-bridge # Marshals HTTP requests/responses across the worker boundary.
api-db-worker-proxy # Worker-side proxy → main-process DB adapter.
api-events-worker-proxy # Worker-side proxy → main-process EventBus.
api-secrets-worker-proxy # Worker-side proxy → main-process secrets adapter.
api-jobs-worker-proxy # Worker-side proxy → main-process job scheduler.
api-logger-worker-proxy # Worker-side proxy → main-process logger.
api-sandbox-permissions # Permission model: fs / fetch host allowlist, CPU/memory/wall-clock budgets.
api-sandbox-vm # vm.Context-based sandbox for non-WASM trusted-but-isolated plugins.
api-sandbox-wasm # WASM target host; runs plugins compiled to WASI with narrow capability set.
# ── Router engine adapters ────────────────────────────────────────────────
api-router-hono # Hono adapter. → api-router, hono peer
api-router-in-memory # In-memory adapter for tests. → api-router
# ── GraphQL engine ────────────────────────────────────────────────────────
api-graphql # Abstract GraphQL contract: schema federation across plugins,
# resolver registry, DataLoader integration, RequestContext bridge,
# subscription dispatch. → api-types, api-router (for HTTP transport)
api-graphql-yoga # GraphQL Yoga engine adapter (default).
api-graphql-apollo # Apollo engine adapter (alternative).
api-graphql-ui-graphiql # GraphiQL UI for /graphql/ui (default).
api-graphql-ui-altair # Altair UI (alternative).
# ── WebSocket / SSE engines ───────────────────────────────────────────────
api-ws # Abstract WebSocket engine: connection registry, subscription bus,
# tenant-scoped fan-out, ping/pong heartbeats, reconnect tokens.
api-ws-bun # Bun.serve WebSocket adapter (default).
api-ws-broker-redis # Cross-host broadcast adapter (Redis pub/sub).
api-ws-broker-in-memory # In-memory broker (single-host + tests).
api-sse # Abstract Server-Sent-Events engine + Last-Event-Id resume.
api-sse-bun # Bun.serve SSE adapter (default).
# ── Auth (session resolvers + provider) ──────────────────────────────────
api-auth # Abstract session-resolver contract: Request → Principal | null.
api-auth-provider # Abstract auth-provider contract: account lifecycle routes.
api-auth-jwt # Session resolver via @suaveplan/jws against JWKS or static key.
api-auth-bearer-static # Session resolver for static service-to-service tokens.
api-auth-anonymous # Always returns null principal.
api-auth-better-auth # Both contracts via Better-Auth; owns its own DB connection.
api-auth-rolled # Reference rolled-our-own provider via genesis crypto/webauthn/otp/srp/hibp.
# ── Tenant resolvers ──────────────────────────────────────────────────────
api-tenant-resolver-jwt # Reads ctx.principal.tenantId.
api-tenant-resolver-header # Header-based resolver (service-to-service traffic).
api-tenant-resolver-subdomain # Subdomain resolver.
api-tenant-provider # Abstract "list tenants this host knows about" — backs perTenant jobs.
api-tenant-provider-memory # In-memory reference impl + test adapter.
# ── Secrets ───────────────────────────────────────────────────────────────
api-secrets # Abstract contract.
api-secrets-env # process.env / Bun.env source.
api-secrets-bun # Bun.secrets (OS keystore).
api-secrets-vault # @suaveplan/vault source.
# ── Database ──────────────────────────────────────────────────────────────
api-db-adapter # Bridge from @suaveplan/db → plugin DatabaseLike.
api-db-sqlite-memory # In-memory adapter for tests.
# ── Distributed transactions ──────────────────────────────────────────────
api-saga # Saga orchestrator: manifest-declared workflows, compensable steps,
# event-bus-backed, persisted state in host metadata DB,
# survives host restart by replaying state. → events, db-adapter
api-2pc # Two-phase commit coordinator for engines that support PREPARE TRANSACTION
# (Postgres). Persists prepared-tx handles in metadata DB. → db-adapter
# ── OpenAPI ───────────────────────────────────────────────────────────────
api-openapi # Emission from manifest + route schemas. Uses @asteasolutions/zod-to-openapi.
api-openapi-ui-scalar # Default /docs UI.
api-openapi-ui-swagger # Alternative.
api-openapi-ui-redoc # Alternative.
# ── Cross-cutting middleware ──────────────────────────────────────────────
api-rbac # Scope + rule-based authorisation. Reuses @suaveplan/rules.
api-rate-limit # Per-tenant / per-principal / per-IP token bucket.
api-audit-stdout # Default audit sink (containers redirect anyway).
api-audit-file # File-rotating audit sink.
# ── Authoring helpers ─────────────────────────────────────────────────────
api-plugin-example # Reference plugin exercising every capability. Load-bearing integration test.
create-suaveplan-plugin # Scaffold via @suaveplan/installer.
The total foundation footprint is ~55 packages. Adapter packages depend only on contracts (api-types plus the relevant abstract package). Plugin authors import types from api-types, never from api-host. Each package stays within the genesis 300-line-per-file constraint via feature-module subdirectories.
The event bus is not an api-family package: plugins publish/subscribe via the existing @suaveplan/events bus (consumed across the monorepo). api-events-worker-proxy and api-events-in-memory are an adapter and a test double over that bus — not a new contract layer.
2.2 Runtime topology — three isolation tiers
Every plugin runs in one of three tiers. Tier is declared in the manifest (isolation: "trusted" | "isolated" | "sandboxed") and gated by host config (allowedIsolationTiers).
+----------------------------------------------------------------------------+
| Host process (api-host) |
| ┌──────────────────────────────────────────────────────────────────────┐ |
| │ Loader │ Manifest │ Lifecycle │ CapabilityInjector │ RouterSlotTable │ |
| └──────────────────────────────────────────────────────────────────────┘ |
| │ |
| ├─── injects ─────────────────────┐ |
| │ │ |
| ▼ ▼ (via api-worker-rpc MessagePort) |
| ┌──────────────────────────┐ ┌──────────────────────────┐ |
| │ Trusted plugin │ │ Worker thread (per plugin)│ |
| │ (same process) │ │ ┌──────────────────────┐ │ |
| │ direct capability call │ │ │ Isolated plugin OR │ │ |
| │ wrapped in err boundary│ │ │ Sandboxed plugin │ │ |
| │ │ │ │ + vm.Context guard │ │ |
| │ │ │ │ + WASI permissions │ │ |
| │ │ │ │ + fs/fetch proxies │ │ |
| │ │ │ │ + CPU/mem budgets │ │ |
| │ │ │ └──────────────────────┘ │ |
| └──────────────────────────┘ └──────────────────────────┘ |
| |
| Capability surface is identical in both — plugin author writes the same |
| definePlugin({...}) regardless of tier; the host decides marshalling. |
+----------------------------------------------------------------------------+
| Tier | Use case | Implementation | Capability call cost |
|---|---|---|---|
trusted |
First-party plugins owned by the host operator | Direct in-process call wrapped in error boundary | Sync; no marshalling |
isolated |
First-party plugins where bulkhead matters (memory leaks, runaway code, segfault-prone native deps) | Node Worker thread per plugin. Every capability call marshalled over MessagePort via api-worker-rpc. |
Async RPC; +50-200μs per call |
sandboxed |
Third-party / marketplace plugins | Worker thread + vm.Context with frozen globals, OR WASM target via api-sandbox-wasm. Permission-gated host proxies for fs/fetch. CPU/memory/wall-clock budgets enforced. |
Async RPC + permission checks |
A plugin crash:
trusted— handler throws → error boundary catches → 500 → plugin staysstarted.isolated/sandboxed— worker dies → main host detects → plugin transitions tocrashed→ auto-restart up tocrashRestartMax(default 3) with exponential backoff → if max exceeded, parked incrashedstate. Operator canPOST /admin/plugins/{name}/restartto retry.
Engine adapters are wired at boot from host.json. Plugins never name a concrete adapter and never know which isolation tier they're running in.
2.3 Auth: two distinct contracts
Conflating session resolution and account lifecycle is what makes most plugin systems painful to integrate with third-party auth.
@suaveplan/api-auth # Session resolver: Request → Principal | null.
# Used on EVERY request. Plugins consume ctx.principal.
@suaveplan/api-auth-provider # Optional. Account lifecycle:
# POST /auth/register, /auth/login, /auth/logout,
# /auth/refresh, /auth/oauth/*, /auth/verify-email, ...
# Only needed if you host your own auth.
Mix-and-match: Better-Auth's provider + its session resolver, OR Clerk/Auth0 hosted auth (no provider) + JWT session resolver. Each provider adapter declares which paths it owns (default /auth/*, configurable). Host reserves the prefix and refuses plugin routes that collide.
Multiple session resolvers can be chained — host config takes an ordered chain, first match wins. JWT for users + bearer-static for service callers is a common pattern.
Foundation auth adapters shipping v1:
api-auth-jwt— verifies via@suaveplan/jwsagainst a JWKS URL or static key. Pairs with hosted IdPs (Clerk, Auth0, Cognito) or self-hosted JWT-issuing providers.api-auth-anonymous— always returns null principal. Default for fully-public APIs.api-auth-bearer-static— static service-to-service tokens. No provider, no DB.api-auth-better-auth— implements bothauth.sessionandauth.providervia Better-Auth. Owns its own DB connection per Better-Auth's opinionated model; maps Better-Auth's user/session shapes onto ourPrincipalcontract. Integration-tested against real Postgres viatesting-integration-postgres. Shipping this on day one is how we know the auth contracts hold up against a non-trivial third-party library — wrapping it surfaces the contract pinch points before they ossify.api-auth-rolled— reference rolled-our-own provider composing@suaveplan/crypto-rsa+@suaveplan/webauthn+@suaveplan/otp+@suaveplan/srp+@suaveplan/hibp+@suaveplan/password-strength+@suaveplan/password-generator. Shares the host's main DB connection (not a separate one). Demonstrates how to compose the genesis crypto/auth primitives into a full provider — also doubles as a fallback for sites that don't want a third-party auth dependency.
3. Plugin manifest schema (plugin.json)
The declarative half of the hybrid contract. Lives at the package root. Validated by api-manifest (Zod) at install AND at every boot — a manifest that drifted between install and reload is a hard failure.
{
// ── Identity ─────────────────────────────────────────────────────────
"$schema": "https://schemas.suaveplan.com/api-plugin/v1.json",
"name": "@acme/billing-plugin",
"version": "1.4.2",
"displayName": "Billing",
"description": "Subscriptions and invoicing.",
"license": "MIT",
// ── Compat ───────────────────────────────────────────────────────────
"apiHost": "^1",
"requiredCapabilities": ["router.streaming", "router.blue-green", "db.transactions", "auth.session"],
"optionalCapabilities": ["events.bounded-queue", "secrets.bun"],
// ── Isolation tier (Section 2.2) ─────────────────────────────────────
"isolation": "isolated", // "trusted" | "isolated" | "sandboxed"
"trustLevel": "first-party", // "first-party" | "third-party" | "untrusted"
// ── Sandbox permissions (only used when isolation=sandboxed) ─────────
"permissions": {
"fs": ["./uploads/*"], // host-proxied fs access allowlist
"fetch": ["api.stripe.com"], // host-proxied outbound HTTP allowlist
"budgets": {
"cpuPercent": 25, // sustained CPU cap
"memoryMb": 512,
"wallClockMs": 30000 // per request wall-clock
}
},
// ── Dependencies on other plugins ────────────────────────────────────
"dependsOn": { "@suaveplan/api-plugin-users": "^1" },
// ── HTTP surface ─────────────────────────────────────────────────────
"routes": {
"prefix": "/billing",
"publicRoutes": ["/webhooks/stripe"]
},
// ── GraphQL surface (optional, requires api-graphql capability) ──────
"graphql": {
"contributes": true,
"types": "graphql/types.graphql", // path to SDL contributions
"resolversFrom": "src/graphql.ts" // export named `resolvers`
},
// ── WebSocket endpoints (optional) ───────────────────────────────────
"websockets": [
{
"path": "/live/orders",
"auth": "orders:read",
"subscriptions": ["orders.created", "orders.updated"],
"maxConnectionsPerTenant": 100,
"heartbeatMs": 30000
}
],
// ── SSE endpoints (optional) ─────────────────────────────────────────
"sse": [
{
"path": "/feed",
"auth": "feed:read",
"subscriptions": ["feed.item"]
}
],
// ── Sagas this plugin participates in or owns ────────────────────────
"sagas": {
"owns": [
{
"name": "create-customer-with-subscription",
"steps": [
{ "plugin": "@suaveplan/api-plugin-users", "action": "createCustomer", "compensate": "archiveCustomer" },
{ "plugin": "@acme/billing-plugin", "action": "createSubscription", "compensate": "cancelSubscription" }
]
}
],
"participatesIn": ["@acme/onboarding-plugin/full-tenant-setup"]
},
// ── 2PC participant declaration ──────────────────────────────────────
"twoPhaseCommit": {
"participates": true // requires db.preparable capability
},
// ── Database (hybrid engine-neutral + escape hatch) ──────────────────
"database": {
"required": true,
"preferredEngine": "postgres",
"compatibleEngines": ["postgres", "sqlite", "surrealdb"],
"schemaVersion": 7,
"migrationsDir": "migrations",
"tenantIsolation": "row-level",
"tablePrefix": "billing_"
},
// ── Background work ──────────────────────────────────────────────────
"jobs": {
"cron": [{ "name": "reconcile-invoices", "expression": "0 3 * * *", "tz": "UTC" }],
"queued": [{ "name": "send-receipt", "maxAttempts": 5 }]
},
// ── Event bus topology ───────────────────────────────────────────────
"events": {
"publishes": ["billing.invoice.created", "billing.payment.succeeded"],
"subscribes": ["users.user.deleted"]
},
// ── Secrets / config ─────────────────────────────────────────────────
"secrets": [
{ "name": "STRIPE_SECRET_KEY", "required": true },
{ "name": "STRIPE_WEBHOOK_SECRET", "required": true }
],
"config": { "schemaPath": "config.schema.json", "defaults": { "trialDays": 14 } },
// ── AuthZ scopes ─────────────────────────────────────────────────────
"scopes": [
{ "name": "billing:read", "description": "Read invoices and subscriptions" },
{ "name": "billing:write", "description": "Create/cancel subscriptions" },
{ "name": "billing:admin", "description": "Refunds and manual overrides" }
],
// ── Observability ────────────────────────────────────────────────────
"telemetry": {
"metricPrefix": "billing.",
"spanPrefix": "billing.",
"counters": [{ "name": "billing.invoices_finalised_total", "labels": ["currency"] }],
"histograms": [{ "name": "billing.stripe.call_duration_ms", "labels": ["operation"] }],
"gauges": [{ "name": "billing.outstanding_invoice_count" }]
},
// ── OpenAPI ──────────────────────────────────────────────────────────
"openapi": {
"contributes": true,
"tagDisplayName": "Billing & Subscriptions",
"externalDocs": "https://docs.acme.io/billing"
},
// ── Error code → HTTP status mapping ─────────────────────────────────
"errors": {
"statusMap": {
"PLUGIN.BILLING.SUBSCRIPTION_INACTIVE": 409,
"PLUGIN.BILLING.INVOICE_ALREADY_FINALISED": 409,
"PLUGIN.BILLING.STRIPE_API_FAILURE": 502
},
"redact": { "PLUGIN.BILLING.STRIPE_API_FAILURE": ["details.stripeKey"] }
},
// ── Uninstall ────────────────────────────────────────────────────────
"uninstall": {
"dataPolicy": "archive", // archive | drop | keep
"archiveTablePrefix": "_archived_billing_"
}
}
Companion src/index.ts (the code half):
import { definePlugin } from "@suaveplan/api-host";
export default definePlugin({
requires: ["db", "auth.session", "events"] as const,
install: async (ctx) => { /* DB migrations auto-run before this; custom seed here */ },
start: async (ctx) => { /* register listeners etc. */ },
stop: async (ctx) => { /* drain handles */ },
uninstall: async (ctx) => { /* archive/drop per manifest policy */ },
routes: (router, ctx) => {
router.get("/invoices/:id", { auth: "billing:read" }, async (req) => { /* ... */ });
},
jobs: {
"reconcile-invoices": async (ctx) => { /* ... */ },
"send-receipt": async (payload, ctx) => { /* ... */ },
},
events: {
"users.user.deleted": async (event, ctx) => { /* ... */ },
},
});
Design rules:
- Manifest is the source of truth for declarative facts (routes prefix, scopes, jobs schedule, events topology, DB engine, capability requirements, secrets). The host validates and acts on these before loading the plugin's code.
- Code half is only for behaviour (handlers, hooks). You can't declare a route in code that wasn't manifested.
- Forward-compatible: every top-level key except
name/version/apiHostis optional. Adding new keys is non-breaking. - Scopes plugins declare are auto-registered into the host's RBAC registry at install. Plugins can invent new scopes — by design, plugins own their authorisation vocabulary.
publicRoutesallowlist is per-plugin and explicit. Default deny — every route requiresauth.sessionunless declared public. "Forgot the auth middleware" stops being a class of bug.uninstall.dataPolicydefaults toarchive. Host config can override.
4. Lifecycle FSM
┌──────────────┐
│ uninstalled │ ◀───── terminal
└──────┬───────┘
│ install()
▼
┌──────────────┐ install hook throws / migration fails
│ installing │ ───────────────────────────────┐
└──────┬───────┘ │
│ install hook + migrations OK ▼
▼ ┌───────────────┐
┌──────────────┐ │ install_failed│ ◀── retryable
│ installed │ └───────────────┘
└──────┬───────┘
│ start()
▼
┌──────────────┐ start hook throws / new module fails import
│ starting │ ───────────────────────────────┐
└──────┬───────┘ │
│ start hook OK ▼
▼ ┌───────────────┐
┌──────────────┐ │ start_failed │ ◀── retryable (auto-retry × 3)
│ started │ ◀── HEALTHY └───────────────┘
└──────┬─────┬─┘ │
│ │ │ rollback triggered
│ │ ▼
│ │ ┌───────────────┐
│ │ │ rolled_back │ ◀── prior known-good
│ │ │ (= started) │ module re-activated
│ │ └───────────────┘
│ │
│ │ (isolation=isolated|sandboxed only)
│ │ worker crash / OOM / wall-clock budget
│ ▼
│ ┌───────────────┐ auto-restart × crashRestartMax ┌──────────────┐
│ │ crashed │ ──────────────────────────────▶ │ starting │
│ └───────────────┘ └──────────────┘
│ │ max exceeded → parked
│ ▼
│ (operator must POST /admin/plugins/{name}/restart)
│
│ stop()
▼
┌──────────────┐ stop hook throws (logged, transition proceeds)
│ stopping │
└──────┬───────┘
│
▼
┌──────────────┐
│ stopped │
└──┬─────┬─────┘
│ │ uninstall()
│ ▼
│ ┌───────────────┐ uninstall hook throws / archive fails
│ │ uninstalling │ ─────────────────────────────┐
│ └──────┬────────┘ │
│ │ archive/drop OK ▼
│ ▼ ┌─────────────────┐
│ ┌──────────────┐ │uninstall_failed │ → quarantined sub-state
│ │ uninstalled │ └─────────────────┘
│ └──────────────┘
│
│ start()
└──────────────── back to "starting" (hot reload path; no install re-run)
| Transition | Side effects |
|---|---|
uninstalled → installing |
Validate manifest, resolve dep graph, acquire DB connection, run migration runner up to schemaVersion, write plugin ledger, register scopes in RBAC, call user install hook |
installing → installed |
Emit plugin.installed, OTel span complete |
installed → starting |
Mount routes under prefix, subscribe to event topics, register jobs (paused), inject capabilities, call user start hook |
starting → started |
Unpause jobs, mark health ready, emit plugin.started |
started → stopping |
Pause jobs, unsubscribe events, unmount routes (503 + Retry-After), call user stop hook with gracefulMs (default 30s) |
stopping → stopped |
Force-cancel stragglers, dispose injected capabilities, emit plugin.stopped |
stopped → uninstalling |
Apply manifest.uninstall.dataPolicy, drop scopes, drop migration ledger entry, call user uninstall hook |
uninstalling → uninstalled |
Emit plugin.uninstalled |
Hot reload re-enters installed without rerunning install unless database.schemaVersion increased, new scopes/events/jobs/secrets were declared, or dependsOn changed. Downgrades (plugin version lower than ledger) → install_failed with VERSION_DOWNGRADE.
Failure recovery:
install_failed— operator-retryable viaPOST /admin/plugins/<n>/retry-install.start_failed— when the failure happened during a hot reload (a prior known-good module exists in theRollbackJournal), the loader automatically transitions the plugin torolled_backby re-activating the previous module specifier — operator gets a non-disruptive failure they can investigate on their own time. Otherwise auto-retry × 3 with@suaveplan/retryexponential backoff before parking; operator can stillretry-startafter parking.rolled_back— semantically equivalent tostarted(the prior known-good version is live). Admin endpoint shows it explicitly so operators see "this plugin rolled back at T, last good version V, failed version V+1, cause:". Operator clears withPOST /admin/plugins/{name}/clear-rollbackafter fixing.crashed— only reachable fromisolated/sandboxedtiers. Auto-restart up tocrashRestartMax(default 3) with exponential backoff; subsequent crashes withincrashRestartWindowMscount against the budget. Budget reset aftercrashRestartCooldownMsof healthy operation. Operator canPOST /admin/plugins/{name}/restartto retry past the budget.uninstall_failed— quarantined sub-state.force-uninstalldrops data with audit log, orrepairafter manual fix-up.- A handler throw or job tick error does NOT transition the FSM; the error boundary handles it and the plugin stays
started. Only catastrophic capability failures (host evicts) or worker death (isolated/sandboxed tiers) trigger out-of-startedtransitions.
The FSM has a per-plugin mutex — at most one transition at a time. Concurrent triggers queue.
Plugin ledger is host-owned in a small dedicated database — by default the host opens a SQLite file at host.config.metadataDbPath (default ./suaveplan-host.db); production deployments override to a Postgres URL or a shared Surreal namespace. The ledger never shares a connection with a plugin's own DB. Table suaveplan_plugin_ledger survives plugin DB recreation; lets one host operate plugins across multiple per-plugin DBs without coordinating ledger schemas. On host restart the host resumes each plugin from its recorded state.
5. Capability injection
5.1 Two context flavours
// Given to install / start / stop / uninstall / job handlers / scheduled tasks.
interface PluginContext<Caps extends CapabilityMap> {
plugin: { name: string; version: string };
logger: LoggerLike; // pre-tagged with plugin name + version
events: EventBusLike;
secrets: SecretsLike; // typed against manifest.secrets[]
config: PluginConfig<Caps>; // typed from config.schema.json
jobs: JobRegistrationHandle;
db?: AdminDatabaseLike; // present iff manifest.database; UNSCOPED for migrations
abortSignal: AbortSignal; // signaled on stop/uninstall
instruments: { // from manifest.telemetry
counters: Record<string, Counter>;
histograms: Record<string, Histogram>;
gauges: Record<string, Gauge>;
};
}
// Given to route handlers + tenant-scoped event/job handlers.
interface RequestContext<Caps> extends PluginContext<Caps> {
request: Request;
principal: Principal | null; // null on publicRoutes
tenantId: TenantId;
requestId: string; // ulid
span: Span;
db?: TenantScopedDatabaseLike; // OVERRIDES; tenant-bound
}
The db slot is the same field name but a different type per context. Migrations and admin work get raw access; request handlers can only see their tenant. Plugins literally cannot read another tenant's rows from a route handler — the tenant-scoped adapter rejects unscoped queries at the boundary.
Default scope in route handlers is tenant-scoped. Admin access from a route is the exception and must be declared explicitly via db.unsafe-queries capability flag. Every such call emits plugin.unsafe_query_total for audit.
5.2 Adapter binding
// host.json (simplified)
{
"capabilities": {
"router": { "adapter": "@suaveplan/api-router-hono" },
"auth.session": { "adapter": "@suaveplan/api-auth-jwt", "config": { "jwksUrl": "..." } },
"tenant.resolver": {
"chain": [
{ "adapter": "@suaveplan/api-tenant-resolver-jwt" },
{ "adapter": "@suaveplan/api-tenant-resolver-header" }
]
},
"secrets": { "adapter": "@suaveplan/api-secrets-bun" },
"db": { "adapter": "@suaveplan/api-db-adapter", "engine": "postgres", "url": "..." }
}
}
Each adapter exports defineCapability({ id, version, scope, provider, disposer }). The host's CapabilityRegistry validates capability versions against every plugin's apiHost + requiredCapabilities. Mismatch → host refuses to start the plugin with CAPABILITY_VERSION_MISMATCH.
5.2a Worker-RPC variants — required on every capability
Every capability adapter ships two callable surfaces from day one:
- Direct surface — the in-process synchronous (or async) capability call used by
trusted-tier plugins.
- Proxy surface — a
MessagePort-marshalled stub used by isolated- and sandboxed-tier plugins.
The host instantiates the right surface based on the plugin's isolation declaration. Plugins author against the same PluginContext / RequestContext types — they cannot tell which surface they hold. The proxy surface uses api-worker-rpc for:
- Request/response correlation via monotonic message ids.
AbortSignal forwarding (cancel propagates main → worker → in-flight call).
- Streaming responses via transferable
ReadableStream (db cursors, large query results).
- Error reconstruction —
SuaveplanError is rebuilt with the same code + chain on the calling side.
- Backpressure — bounded outstanding-message budget per worker; exceeding triggers
RPC_BACKPRESSURE before exhausting memory.
- Structured-clone-safe boundary — non-cloneable values (functions, class instances without explicit
serialize hooks) → RPC_NOT_CLONEABLE at the call site, not silent corruption.
Every conformance suite (Section 13) runs against both surfaces. An adapter that passes only one is not shippable.
5.2b Sandboxed-tier capability gating
Sandboxed plugins get the same proxy surface plus an additional permission layer enforced by the host before each call:
db — query gated by manifest's declared database (plugin can only query its own DB scope).
secrets — only manifested secrets are readable.
events — only manifested publishes topics are emittable; only manifested subscribes topics deliverable.
fetch (host-proxied) — destination host must match permissions.fetch[].
fs (host-proxied) — path must match permissions.fs[].
- All others — proxy surface unchanged from
isolated tier.
Permission denials emit plugin.permission_denied_total{plugin, capability, reason} and surface as PERMISSION_DENIED errors inside the plugin.
5.3 Type safety
definePlugin is generic over the capability map; manifest's requiredCapabilities is the type's source of truth:
export default definePlugin({
requires: ["db", "auth.session", "events"] as const,
routes: (router, ctx) => {
ctx.db.query(...); // ✓ typed
ctx.events.emit(...); // ✓ typed
// ctx.secrets.get(...); // ✗ TS error — not declared
},
});
Manifest and code must agree. Plugin can't accidentally use a capability it didn't declare.
5.4 Per-tenant jobs
"jobs": {
"cron": [{ "name": "send-monthly-statements", "expression": "0 0 1 * *", "perTenant": true }]
}
perTenant: true makes the host fan out — enumerate tenants from the tenant.provider capability, spawn one job invocation per tenant, each receiving a tenant-scoped context for the run. tenant.provider ships in v1 (in-memory reference impl + adapter slot) so retrofitting tenant enumeration into a stable contract doesn't have to happen later.
5.5 Disposal
Following @suaveplan/network's PluginManager pattern: register forward, dispose reverse. On stop(), capabilities dispose in reverse-registration order — request-scoped first, logger last (so disposal logs still work).
6. Per-request data flow
Fixed pipeline. Plugins can only insert middleware between steps 7 and 8 — never before auth, RBAC, or validation. That surface was a CVE waiting to happen.
1. HTTP intake (api-router-* adapter) — parse, ULID requestId, root span, abort on socket close
2. Route match (host) — radix tree built from all plugin manifests
3. Auth resolver (api-auth-* adapter) — sets ctx.principal; 401 only if non-public && null
4. Tenant resolver chain (api-tenant-resolver-*) — first principal.tenantId, then fallback resolvers
4a. Rate limit (optional) (api-rate-limit) — per-tenant or per-principal token bucket
5. RBAC gate (optional) (api-rbac) — required scopes vs principal.scopes; rule policy via @suaveplan/rules
6. Body/params/query parse + validate — Zod runtime gate (route.runtimeValidation, default true)
6a. Idempotency hook (@suaveplan/idempotency) — when route is idempotent + Idempotency-Key header present
7. Capability scoping (host) — ctx.db ← tenant-scoped wrapper; ctx.logger ← per-request child
8. Plugin route handler (user code) — error-boundary wrapped
9. Response validation (host) — handler return vs route.responses[].body if route.openapi/runtimeValidation
10. Response composition (host + engine) — serialize, ETag, X-Request-Id, Server-Timing, encoding
11. Telemetry close (host) — span.end, api.request_duration_ms{...}, api.requests_total{...}
Streaming routes (streaming: true) bypass steps 6 and 10's buffering — handler reads/writes ReadableStream directly.
For isolated / sandboxed plugins, step 8 marshals RequestContext (minus the raw Request object) and the validated body / params / query across the MessagePort to the worker. Streams are transferred via Transferable (zero-copy). The handler's return value flows back the same way. Steps 1–7 and 9–11 always run in the main process — auth, RBAC, validation, tenant scoping, and telemetry are NEVER in worker hands. This means worker plugins gain isolation without losing any host-enforced security guarantees.
Cookie-based auth adapters trigger CSRF verification using Bun.CSRF in step 3 for non-GET/HEAD methods. Default ON for cookie auth, opt-out per route via csrf: false. JWT/Bearer auth skips CSRF (no implicit cred submission).
Response validation strictness in production defaults to enforce (return 500 if violation, fail closed on contract breach); warn in dev (return body + log + counter); per-route override allowed.
Every step short-circuits to error response on throw → error boundary → standard envelope:
{
"error": {
"code": "PLUGIN.BILLING.SUBSCRIPTION_INACTIVE",
"message": "No active subscription for tenant.",
"status": 409,
"requestId": "01HZX...",
"traceId": "0af7651916cd43dd8448eb211c80319c",
"details": { /* schema'd per code; redacted */ }
}
}
X-Trace-Id mirrors traceId in the response header so an end-user pasting it into a support ticket lets an operator find the span instantly.
7. OpenAPI
Auto-generated from route Zod schemas + manifest metadata. Plugin authors write Zod schemas on routes; OpenAPI assembles itself.
router.post("/invoices", {
auth: "billing:write",
summary: "Create an invoice",
tags: ["invoices"], // appended after auto-tag (plugin displayName)
request: { body: BodySchema },
responses: { 201: { body: InvoiceSchema }, 409: { body: ErrorSchema } },
runtimeValidation: true, // default true; false skips 400 gate
openapi: true, // default true; false omits from /openapi.json
}, async (req) => { /* req.body typed AND validated at runtime */ });
runtimeValidation and openapi are independently togglable. Internal endpoints can have validation but no docs; legacy ones can have docs but no runtime gate.
@suaveplan/api-openapi reads:
- Each plugin's manifest (
name, displayName, description, scopes, route prefix, publicRoutes).
- Each plugin's registered routes (method/path/summary/tags/request/response schemas).
- The active auth adapter (
securitySchemes — Bearer JWT, API key, OAuth2 flows, …).
- Host config (server URLs, contact, license, top-level info).
Endpoints:
GET /openapi.json # full merged document
GET /openapi.yaml # yaml flavour
GET /docs # Scalar (default). Pluggable: api-openapi-ui-{scalar,swagger,redoc}
GET /docs/{plugin} # per-plugin filtered view
Namespacing:
- Tags auto-set to plugin
displayName. Per-route tags append.
- Components/schemas namespaced:
#/components/schemas/billing.Invoice.
- operationId =
<pluginName>.<method>.<routePath> — deterministic.
- Security — every route inherits the auth adapter's default scheme unless on
publicRoutes.
Zod-to-OpenAPI conversion lives in api-openapi only. Uses @asteasolutions/zod-to-openapi (established, OpenAPI 3.1, handles refs/discriminators/coercion). Plugins import z from zod directly per the genesis convention.
openapi.generation is an optional capability. Plugins without schemas still mount routes; they appear as "no schema declared." Host config openapi.strictness: "warn" | "error" controls whether missing schemas log a warning or refuse install.
Merged doc info.version = host version. Each plugin contributes x-plugin-version extension on its tag, so consumers can detect plugin upgrades. Bumping a plugin's major version bumps its routes' operationId namespace (billing.v2.create-invoice) — non-breaking for clients still consuming v1 routes.
8. Hot reload
Two trigger paths, one underlying primitive.
8.1 Underlying reload primitive — blue-green by default
acquire(P.mutex)
1. P.state ∈ {started, rolled_back} → assert. Otherwise reject.
2. Snapshot the current module specifier + manifest + migration-ledger
position into RollbackJournal[P]. Truncate journal to last 3.
3. Re-resolve manifest from disk. Compute reinstall criteria (8.6).
4. Import the new module under a fresh specifier (?v=N+1).
Native binding present → refuse reload with NATIVE_BINDING_HELD.
5. If reinstall criteria met → run migrations forward against P's DB
(forward-only; migrations are idempotent).
6. Mount the new module's routes under a SHADOW slot in the router
(same prefix, distinct version pointer). Run start() against the
new module. Run health probe.
a. Health probe fails OR start() throws → transition P to
start_failed. Atomically discard the shadow slot. Re-activate
the previous specifier from RollbackJournal[P] (cheap — module
object still in WeakRef-pinned cache). Transition P to
rolled_back. Emit plugin.rolled_back. Done.
b. Health probe passes → atomically flip the router's version
pointer for P from "live=N, shadow=N+1" to "live=N+1,
draining=N". New requests immediately hit N+1.
7. In-flight requests on the now-draining N slot continue against
the old module. Slot disposed when all settle (or after
forceTimeoutMs default 30s, then ctx.abortSignal fires).
8. Old module released from cache; WeakRef may collect.
release(P.mutex)
The two module versions coexist briefly. Per-plugin mutex makes burst file-save events safe (queue, intermediate ones are no-ops, last runs). The router's per-plugin version pointer is the single atomic point that determines "which module gets this request" — Atomics.store on a SAB-backed integer.
host.config.blueGreen: false opts a deployment back into the simpler drain-then-replace mode (Section 8.5 below) for environments that cannot tolerate two concurrent module instances (e.g. plugins holding exclusive native locks). The router and lifecycle paths shipped support both modes; the choice is host-config-level, not per-plugin.
8.1a RollbackJournal
Per-plugin ring buffer (default depth 3) recording the last known-good specifiers. Each entry:
{
"specifier": "file:///abs/path/dist/index.js?v=42",
"manifestHash": "sha256:...",
"moduleRef": /* WeakRef<Module>; nullable after GC */,
"migrationCursor": 7, // schemaVersion at the time
"capturedAt": "2026-05-13T08:14:22.014Z",
"version": "1.4.2" // from manifest
}
Persisted entries (manifest hash, migration cursor, version) survive host restart by being written to the plugin ledger; the moduleRef is in-memory only and lost on restart. After a restart, rollback is no longer auto-zero-cost — the host has to re-import the previous specifier. The host can still do this; the cost is one additional module import.
Operator can inspect via GET /admin/plugins/{name}/rollback-history and force a manual rollback with POST /admin/plugins/{name}/rollback-to/{version} — useful for "the new module passed health probe but is producing bad results in production."
8.2 Module cache busting
Bun ESM cache is URL-keyed. Re-import with same URL → same instance. We bust with a query suffix:
import(`${moduleUrl}?v=${reloadCounter}`)
ESM treats file:///x.js and file:///x.js?v=2 as different specifiers → fresh evaluation. Old module held by a WeakRef for GC purposes; host's reference to the new module replaces it atomically.
Transitive deps reload for free — bumping the importing module's specifier re-resolves the whole subgraph. Native bindings (.node files) cannot be reloaded; loader walks the dep graph at install time and refuses reload if any are present (logs RELOAD.NATIVE_BINDING_HELD, advises full restart).
8.3 Trigger 1 — File-watch in dev
Active when host.config.devMode === true (default false).
- One watcher per loaded plugin against
packages/api/<plugin>/dist/** (or src/** if running TS via Bun).
- Debounce 100 ms.
- Coalesce by plugin.
- Glob ignore: tests,
.md, coverage/, .turbo/.
plugin.json always triggers reload regardless of ignore.
- Cross-plugin invalidation respects
dependsOn — dependents reload after their dep returns to started.
8.4 Trigger 2 — Admin API
Always available. Mounted under host.config.adminPrefix (default /admin, configurable; null to disable).
POST /admin/plugins/{name}/reload
POST /admin/plugins/{name}/disable
POST /admin/plugins/{name}/enable
POST /admin/plugins/{name}/install
POST /admin/plugins/{name}/uninstall
POST /admin/plugins/{name}/retry-install
POST /admin/plugins/{name}/retry-start
POST /admin/plugins/{name}/force-uninstall
GET /admin/plugins
GET /admin/plugins/{name}
GET /admin/capabilities
Protection:
- Auth required, no
publicRoutes bypass.
- Default scope
host:admin enforced by RBAC.
- Audit: every state-changing call emits
host.admin_action.
- Rate-limited per-principal (60 ops/min; destructive endpoints 1/min).
- Optional mutual-TLS gate when paired with
api-auth-bearer-static for destructive endpoints.
adminPrefix: null disables HTTP admin; admin operations then only via host signals + a CLI binary over Unix socket. v1 ships this option for sidecar-only deployments.
8.5 In-flight requests during reload
Blue-green mode (default). Two slots coexist (live + draining). New requests go to the live slot; in-flight on the draining slot complete against the old module. Old module disposed when:
- all in-flight settle, OR
drainDeadlineMs (default 30s) elapses → ctx.abortSignal fires on stragglers; non-terminating handlers return 503 and continue in background until forceTimeoutMs (default 60s) at which point the worker (isolated/sandboxed) or the Promise (trusted) is force-cancelled and the return discarded.
503-window mode (blueGreen: false). Single slot. Router flips live → draining → replaced atomically. New requests during the drain window get 503 + Retry-After: 1. Two sub-modes:
forceCancel: false (default in prod): host waits for in-flight to finish before swapping. Mutex has hard ceiling reloadMutexMaxMs default 5min → RELOAD.MUTEX_DEADLINE_EXCEEDED.
forceCancel: true (default in dev): ctx.abortSignal fires immediately; handler that doesn't terminate within forceTimeoutMs returns 503 and continues in background (return discarded).
8.6 Reinstall criteria
Re-install runs only when:
manifest.database.schemaVersion increased (pending migrations execute).
- New
scopes, events.publishes, events.subscribes.
- New
secrets.
dependsOn changed.
Everything else (handler code, route options, cron expression, response schemas) → skip install, go straight to start. Reload ledger records which branch fired.
8.7 Failure modes
Failure
Outcome
Recovery
stop hook throws (on old module during drain)
Logged, drain proceeds
Best-effort; not blocking
New module fails import
Plugin → rolled_back (auto), shadow slot discarded, previous live slot continues serving. Emit plugin.rolled_back.
Fix file. Next file-watch / admin reload runs again. Operator clears via clear-rollback once fixed.
New module's start throws
Same as above — auto-rollback to previous specifier from RollbackJournal.
Same.
New module's health probe fails
Same — auto-rollback before any traffic flips to the new slot.
Same.
Migration fails midway
Plugin → install_failed. Old module continues serving (blue-green) or 503 (window mode). Ledger records partial state.
Fix migration + retry-install (idempotent migrations re-run from last successful), or force-uninstall.
Reload mutex held > deadline
Caller gets RELOAD_BUSY.
Wait + retry.
RollbackJournal exhausted (host restart cleared in-memory module refs, AND no entries persisted)
Plugin → start_failed (true terminal). Operator action required.
Manual deploy of prior known-good version + admin install.
Rollback is "best-effort cheap on the happy path, always-recoverable on the unhappy path." The in-memory WeakRef makes rollback effectively free on the most common case (reload immediately after a bad save); persisted journal entries make it possible (with one extra import) after a restart; if both fail, operators are no worse off than before — they redeploy.
8.8 Operator UX
GET /admin/plugins/{name} returns terminal-friendly JSON:
{
"name": "@acme/billing",
"version": "1.4.2",
"state": "started",
"since": "2026-05-12T18:31:08Z",
"manifestHash": "sha256:7af9...",
"schemaVersion": 7,
"lastReload": {
"at": "2026-05-12T18:31:08Z",
"trigger": "filewatch",
"ranMigrations": false,
"drainedRequests": 12,
"durationMs": 184
},
"lastError": null,
"capabilities": {
"required": { "router": "1.0.0", "db": "1.0.0", "auth.session": "1.0.0", "events": "1.0.0" },
"optional": { "events.bounded-queue": "1.0.0" },
"missing": []
}
}
9. Distributed transactions
Cross-plugin writes against multiple databases (or even a single DB shared by plugins with row-level tenant isolation) need transactional guarantees that no single plugin can give. Two engines ship in the foundation; plugins opt into whichever fits their workflow.
9.1 api-saga — orchestrated, compensable workflows (default)
The saga engine is the recommended path for "create a customer + open a subscription + send a welcome email" workflows where strict ACID isn't required but eventual consistency with rollback semantics is. The saga itself is declared in the saga-owning plugin's manifest:
"sagas": {
"owns": [{
"name": "create-customer-with-subscription",
"timeoutMs": 60000,
"steps": [
{ "plugin": "@suaveplan/api-plugin-users", "action": "createCustomer", "compensate": "archiveCustomer" },
{ "plugin": "@acme/billing-plugin", "action": "createSubscription", "compensate": "cancelSubscription" },
{ "plugin": "@acme/email-plugin", "action": "sendWelcome", "compensate": null }
]
}]
}
The orchestrator (api-saga):
- Persists every saga instance in the host metadata DB (
suaveplan_saga_instances + suaveplan_saga_steps). Survives host restart by replaying state.
- Drives steps in declared order. On each step, emits
saga.step.requested on the bus with {sagaId, stepName, input}. Target plugin's actions[stepName] handler runs, returns either success-with-output or failure-with-cause.
- Idempotency contract: every step's action and compensate handlers MUST be idempotent (keyed by
{sagaId, stepName}). Host wraps every call in @suaveplan/idempotency so a retried step doesn't produce a second record. Plugins that need to enforce idempotency manually opt out via idempotency: "manual" in their action declaration.
- On any step failure: orchestrator runs
compensate handlers in reverse order over completed steps. Compensations are themselves idempotent + retryable up to maxCompensationAttempts.
- After successful compensation: saga ends in
compensated state; failure events emitted.
- After successful completion:
completed state.
- Unrecoverable compensation failure:
compensation_failed state; alert emitted; operator intervention required (admin endpoint to manually mark steps).
Plugins participate in sagas they don't own simply by declaring sagas.participatesIn[] and exporting matching actions and compensations named in the saga's step declaration. The host validates at install that every step's named plugin exists, exports the named action, and has matching compensate (where declared).
Step actions execute with a RequestContext whose principal is the saga's initiator principal (carried from the request that started the saga) and whose tenantId is the saga's target tenant (carried from the input). Tenant isolation is preserved — a saga step cannot read another tenant's data unless the plugin declares saga.cross-tenant.
OTel surface:
saga.instance span per saga lifetime
saga.step span per step attempt; attr: stepName, attempt, outcome
saga.compensate span per compensation
saga.instances_total { name, outcome }
saga.step_duration_ms histogram { name, step, outcome }
saga.compensations_total { name, step }
saga.in_flight gauge — instances currently running
9.2 api-2pc — two-phase commit coordinator
For workflows that genuinely require atomic cross-DB writes (no anomaly tolerable; classic finance/inventory scenarios), api-2pc provides a coordinator on top of engines that support PREPARE TRANSACTION (Postgres is the only mainstream one). Surreal and SQLite plugins cannot participate as 2PC writers — they can only join as read-only voters.
import { run2pc } from "@suaveplan/api-host";
await run2pc({
participants: [
{ plugin: "@suaveplan/api-plugin-users", action: "debit", input: { ... } },
{ plugin: "@acme/billing-plugin", action: "credit", input: { ... } },
],
timeoutMs: 5000,
}, ctx);
Phase 1: coordinator sends prepare to every participant. Each runs the work inside a transaction and issues PREPARE TRANSACTION '<participantTxId>'. Returns vote_commit or vote_abort. Phase 2: if all vote commit → coordinator issues COMMIT PREPARED everywhere; if any vote abort or timeout → ROLLBACK PREPARED everywhere.
Coordinator state in suaveplan_2pc_transactions table — survives host restart. On restart, coordinator queries every participant's pg_prepared_xacts for any orphaned prepared transactions matching the host's instance ID and resolves them (commit if log says all-voted, rollback otherwise).
Plugins participating in 2PC declare twoPhaseCommit.participates: true and require the db.preparable capability (refused at install if the bound DB engine doesn't support it).
Operational warning: 2PC holds row-level locks across the prepare → commit window. Bad for throughput, lethal for high-contention writes. The orchestrator emits 2pc.prepare_duration_ms and 2pc.commit_duration_ms so operators see lock-holding times. Default timeoutMs 5s with hard ceiling 30s — long prepares are treated as failures.
9.3 Choosing between them
Scenario
Engine
Async workflow across services, eventual consistency OK
Sagas
User-facing transactional operation that must succeed-or-undo atomically
Sagas (compensate covers the undo)
Mixed-engine plugins (Postgres + Surreal + SQLite)
Sagas (2PC can't span)
Financial / inventory writes where partial visibility is unacceptable
2PC
Read-modify-write across plugins on the same Postgres
2PC
Plugin author isn't sure
Default to sagas
Both engines are first-class. The reference plugin (api-plugin-example) demonstrates both.
10. GraphQL surface
GraphQL is a first-class engine alongside REST. Plugins can mount GraphQL types and resolvers; the host federates a single schema across all plugins.
10.1 Plugin authoring
// src/index.ts
export default definePlugin({
requires: ["db", "auth.session", "graphql"] as const,
routes: (router, ctx) => { /* REST routes still allowed in parallel */ },
graphql: (registry, ctx) => {
registry.addTypes(/* GraphQL */ `
extend type Query {
invoices(limit: Int = 20): [Invoice!]!
}
extend type Mutation {
createInvoice(input: CreateInvoiceInput!): Invoice!
}
type Invoice {
id: ID!
amountCents: Int!
createdAt: DateTime!
customer: Customer! # cross-plugin ref
}
input CreateInvoiceInput {
amountCents: Int!
currency: String!
customerId: ID!
}
`);
registry.addResolvers({
Query: {
invoices: async (_p, args, rctx) => {
// rctx is RequestContext — principal, tenantId, db all tenant-scoped
return rctx.db.query("SELECT * FROM invoices LIMIT $1", [args.limit]);
},
},
Mutation: {
createInvoice: async (_p, args, rctx) => { /* ... */ },
},
Invoice: {
// Cross-plugin field — delegated to users-plugin's resolver
customer: (parent, _a, rctx) => rctx.loaders.customer.load(parent.customerId),
},
});
// DataLoaders registered per-request automatically by the host
registry.addLoader("customer", async (ids, rctx) => {
return rctx.bus.requestReply("users.customer.batchGet", { ids });
});
},
});
10.2 Federation
The host (api-graphql) builds one merged schema across all plugins at boot and on every reload. extend type Query { ... } from multiple plugins is unioned. Cross-plugin type references work because every plugin contributes to the same merged type graph; resolvers from one plugin can produce values consumed by resolvers from another plugin via DataLoaders.
Schema collisions (two plugins declaring the same field on the same type) → boot fails with GRAPHQL.SCHEMA_COLLISION. Manifest can declare graphql.namespace to scope a plugin's contributions under a wrapper type — e.g. BillingQuery rather than directly on Query.
10.3 Pipeline integration
A GraphQL request flows through the same per-request pipeline as REST (Section 6). Step 8 (handler) dispatches into the GraphQL executor instead of a single REST handler. Inside resolvers:
rctx.principal / rctx.tenantId / rctx.db exactly as for REST.
auth / RBAC checks on resolvers via the @auth(scope: "billing:read") schema directive — the directive emits the same RBAC gate at the resolver call site.
- Per-field rate limiting via
@rateLimit(rpm: 60) directive.
- DataLoader instances are per-request, populated at step 7 (capability scoping) and disposed at step 11.
10.4 Subscriptions
GraphQL subscriptions ride the WebSocket engine (Section 11). The host wires graphql-ws protocol over api-ws; subscription resolvers return AsyncIterable driven by the event bus.
registry.addResolvers({
Subscription: {
invoiceCreated: {
subscribe: (_, _a, rctx) => rctx.bus.asyncIterator("billing.invoice.created", { tenant: rctx.tenantId }),
},
},
});
10.5 Engine choice
Default engine is api-graphql-yoga (GraphQL Yoga, Bun-friendly, fastest for our usage). api-graphql-apollo available for shops standardised on Apollo's broader feature set (Apollo Studio, persisted queries, etc.). Both implement the same api-graphql contract; switching is host-config only.
10.6 Endpoints
POST /graphql # main endpoint
GET /graphql/sdl # merged SDL
GET /graphql/ui # GraphiQL (or Altair via api-graphql-ui-altair)
WS /graphql/ws # subscriptions
10.7 Coexistence with REST + OpenAPI
A route can be REST-only, GraphQL-only, or both — same handler logic factored into a plugin-internal function that's called from both surfaces. The OpenAPI doc covers REST routes; the GraphQL SDL covers GraphQL operations. The two don't merge.
11. WebSocket and SSE engines
Persistent connections are first-class. Plugins declare endpoints in the manifest; the host runs the engine, manages connection lifecycle, fans out subscriptions.
11.1 WebSocket — connection lifecycle
export default definePlugin({
requires: ["ws", "auth.session"] as const,
websockets: {
"/live/orders": {
onConnect: async (conn, ctx) => {
// ctx.principal + ctx.tenantId already resolved at handshake
conn.subscribe("orders.created", { tenant: ctx.tenantId });
conn.subscribe("orders.updated", { tenant: ctx.tenantId });
},
onMessage: async (conn, msg, ctx) => {
// Optional — plugins can also be receive-only
if (msg.type === "ack") conn.ack(msg.id);
},
onClose: async (conn, ctx, info) => {
// info.reason, info.code, info.wasClean
},
},
},
});
Handshake flow:
- WebSocket upgrade request → same per-request pipeline (Section 6) steps 1–7.
- At handshake completion, the host invokes the plugin's
onConnect with a Connection object bound to principal + tenantId. The handshake principal is frozen for the connection's lifetime — token expiry mid-connection emits a connection.principal_expired event the plugin can listen for to force-disconnect.
- Connection enters the per-plugin
ConnectionRegistry with an index by tenantId.
11.2 Subscription bus
conn.subscribe(topic, { tenant?, filter? }) registers the connection with the host's subscription bus. Any plugin emitting bus.broadcast(topic, { tenant, payload }) triggers fan-out:
- Host looks up matching connections from
ConnectionRegistry by (topic, tenant).
- Optional
filter predicate runs per-connection (e.g. "only deliver if payload.priority >= conn.minPriority").
- Payload encoded once, sent to each matching connection.
Cross-tenant fan-out requires ws.broadcast-cross-tenant capability (audit-logged on every use). Default deny.
11.3 Multi-host fan-out
Single host is the default. Production deployments with multiple host replicas wire api-ws-broker-redis (or another broker adapter) — bus.broadcast writes to Redis pub/sub, every host receives, each delivers to its locally-connected subscribers. Connections are NOT migrated across hosts; the broker just makes broadcasts visible everywhere. Sticky sessions are NOT required.
11.4 Backpressure + heartbeats
- Per-connection send queue is bounded (
maxQueuedMessages, default 256). Exceeding → connection receives a BACKPRESSURE_EVICTED close frame and is dropped.
- Ping/pong heartbeats every
heartbeatMs (default 30000). Two missed pongs → connection considered dead, onClose fired with code: ABNORMAL, slot released.
- Per-tenant connection caps (
maxConnectionsPerTenant); exceeding → handshake rejected with 429.
11.5 Reconnect tokens
On onClose the host issues a reconnectToken (HMAC-signed, 5-minute TTL, contains {tenant, principalId, lastEventId}). Client reconnects with ?reconnectToken=...; host validates and resumes subscriptions automatically — the plugin's onConnect runs in resume mode with the previous subscription set pre-restored.
For SSE, the same idea uses Last-Event-Id (an HTTP standard) — host buffers the last eventBufferSize events per (tenant, topic) for eventBufferMs and replays on reconnect.
Where the event buffer for resume lives (in-memory single-host, Redis when the broker is Redis-backed, or fully pluggable) is one of the open implementation questions in Section 14 — the default direction is "Redis when the broker is Redis, in-memory otherwise."
11.6 SSE — simpler cousin
SSE endpoints have the same subscription model but no client → server channel and no ack semantics. Useful for activity feeds, dashboards, notifications. Reuses the same bus + broker; just a different transport. Falls back gracefully on networks that block WebSocket (corporate proxies, etc.).
11.7 OTel surface
ws.handshake.duration_ms histogram { plugin, path }
ws.connections gauge { plugin, tenant }
ws.messages_in_total counter { plugin, path }
ws.messages_out_total counter { plugin, path }
ws.broadcasts_total counter { topic, tenant, recipients }
ws.backpressure_evicted_total counter { plugin }
ws.heartbeat_failures_total counter { plugin }
sse.connections gauge { plugin, path, tenant }
sse.events_sent_total counter { plugin, topic }
11.8 Worker-tier plugins
Worker-isolated plugins still own their onConnect / onMessage / onClose handlers — the host marshals connection events over api-worker-rpc. The Connection object in the worker is a proxy; conn.subscribe / conn.send / conn.close become async RPC calls. Connection state (principal, tenant, subscriptions) lives in the main host; the worker holds a handle.
12. Errors & observability
12.1 Error code namespacing
PLUGIN.<NAME>.<CODE>. Status mapping declared in manifest, not code. Codes thrown that aren't in the map → 500 with code preserved (operator sees real code; client sees opaque 500). Prevents 200-with-error-envelope on a forgotten map entry.
12.2 Redaction
Two layers:
- Per-code manifest policy (
errors.redact["PLUGIN.X.Y"]: ["details.field"]).
- Auto-redaction via
@suaveplan/data-masking for emails, credit-card shapes, JWT shapes. Disable-able per-code.
Dev: details shows raw cause chain. Prod: only what per-code schema allows.
12.3 OTel — what the host emits for free
Spans (per HTTP request, nested under api.request):
api.request
api.route_match
api.auth.resolve { auth.adapter, auth.outcome }
api.tenant.resolve { tenant.resolver_used }
api.rbac.check { rbac.required, rbac.outcome }
api.body.parse { body.size_bytes, body.media_type }
api.body.validate { validation.outcome }
plugin.handler { plugin.name, plugin.version, route }
(plugin's own child spans)
api.response.validate
api.response.compose { response.size_bytes, response.encoding }
Metrics:
api.requests_total { route, status, plugin, tenant(opt-in) }
api.request_duration_ms histogram, same labels
api.body.bytes histogram { plugin, route }
api.response.bytes histogram { plugin, route }
api.errors_total { code, plugin, route }
api.auth.failures_total { reason }
api.rbac.denials_total { plugin, route, missing_scope }
api.tenant.unresolved_total { route, principal_present }
api.validation.failures_total { plugin, route, where }
api.idempotency.replays_total { plugin, route }
api.rate_limit.exceeded_total { plugin, route, key_kind }
plugin.lifecycle_transitions_total { plugin, from, to, outcome }
plugin.reload_total { plugin, trigger, outcome }
plugin.reload_duration_ms histogram { plugin, trigger }
plugin.unsafe_query_total { plugin }
host.admin_action_total { action, outcome }
Tenant label is opt-in to keep cardinality manageable on big multi-tenant deployments.
Logs: every span emits one structured log line on close. ctx.logger is a child logger pre-tagged with plugin, request_id, trace_id, tenant_id.
12.4 Plugin-declared telemetry
Plugins declare instruments in manifest (Section 3). Host instantiates them at install; injects ctx.instruments. This is the only way plugins emit metrics — manifest is the SoT for what metrics exist.
12.5 Audit
Every state-changing admin action AND every non-2xx response on an auth-required route produces:
{
"ts": "2026-05-13T08:14:22.014Z",
"type": "admin.plugin.uninstall" | "request.error" | "auth.failure",
"principal": { "id": "user_01HZ...", "scopes": ["host:admin"] },
"tenant": "acme",
"request_id": "01HZX...",
"trace_id": "0af7...",
"subject": "@acme/billing",
"outcome": "success" | "failure",
"cause": { "code": "...", "message": "..." }
}
Sink pluggable via audit capability. Default api-audit-stdout (containers redirect anyway). api-audit-file for old-school single-host. Plugin-emitted audit records are host-signed (HMAC over canonical bytes) so tampering is detectable.
12.6 Health and readiness
GET /internal/livez → 200 if process is responding (no plugin checks)
GET /internal/readyz → 200 only if every plugin's state is "started"
(or an explicit allowlist); else 409 + structured body
Plugins register a health capability hook returning { status: "ok"|"degraded"|"unhealthy", details? }. Periodic background check (10s default) populates readyz. Plugin reporting unhealthy for >healthFailWindowMs emits unhealthy event; host config can auto-disable repeatedly-unhealthy plugins (off by default).
12.7 Error boundary placement
┌── plugin handler throws ─────────────────┐
│ │
HTTP intake → ... → handler → response validation → compose → done
│ │
▼ │
error boundary │
│ │
├─ SuaveplanError → manifest statusMap → ──┘
├─ AbortError → 499 (client gone) ─────────┘
└─ anything else → 500 + log cause ────────┘
Every other middleware in the pipeline is wrapped the same way. The boundary is the only code allowed to mutate the response after a throw.
13. Testing strategy
13.1 Three layers
Layer 1 Unit per package, per feature, in-memory, 100% coverage
Layer 2 Plugin contract per plugin, in-memory host, real capabilities
Layer 3 Integration per plugin, testcontainers + real Bun.serve
No vi.mock / vi.stubGlobal anywhere. Real implementations only.
13.2 Host testing-utility kit
@suaveplan/api-host/testing subpath ships real, in-memory implementations of every capability. Production-grade adapters that happen to keep state in process memory — not test doubles.
import { createTestHost } from "@suaveplan/api-host/testing";
import billingPlugin from "../src/index.js";
import billingManifest from "../plugin.json";
import { describe, it, expect } from "@suaveplan/testing/runner";
describe("billing plugin", () => {
it("creates an invoice", { timeout: 5000 }, async () => {
await using host = await createTestHost({
plugins: [{ manifest: billingManifest, module: billingPlugin }],
adapters: {
router: "in-memory",
"auth.session": { kind: "static-principal", principal: { id: "u1", scopes: ["billing:write"], tenantId: "acme" } },
"tenant.resolver": "from-principal",
db: { engine: "sqlite-memory" },
secrets: { values: { STRIPE_SECRET_KEY: "sk_test_..." } },
events: "in-memory",
jobs: { clock: "fake" },
},
});
const res = await host.fetch("POST", "/billing/invoices", {
body: { amountCents: 100_00, currency: "USD" },
});
expect(res.status).toBe(201);
expect(host.events.published("billing.invoice.created")).toHaveLength(1);
});
});
await using cleans up on scope exit. No afterEach, no leaked handles.
In-memory adapters live alongside production ones: api-router-in-memory, api-auth-static-principal, api-db-sqlite-memory, api-events-in-memory, etc. Same conformance suites apply to both.
13.3 FakeClock
Time and timers caller-supplied via @suaveplan/types Clock and TimersLike. jobs: { clock: "fake" } injects FakeClock + FakeTimers from @suaveplan/testing. Tests advance deterministically:
host.clock.advance("1h");
await host.jobs.runScheduled();
13.4 Capability conformance suites
Each abstract capability ships a conformance suite in its ./testing subpath (same pattern @suaveplan/i18n-shared/testing uses):
// @suaveplan/api-router/testing.ts
export function runRouterConformance(label: string, makeFactory: () => RouterFactory): void {
describe(`${label} conforms to Router contract`, () => {
it("matches static routes", { timeout: 5000 }, async () => { /* ... */ });
it("matches param routes", { timeout: 5000 }, async () => { /* ... */ });
// ~30 invariants
});
}
Every adapter runs the suite. Adding a new adapter that passes the suite is automatically known good. Adding a new invariant to the suite automatically enforces it across every existing adapter.
Per capability: runRouterConformance, runAuthSessionConformance, runTenantResolverConformance, runDbAdapterConformance, runSecretsConformance, runEventsBusConformance, runJobsConformance.
13.5 Reference plugin as load-bearing test
@suaveplan/api-plugin-example exercises every contract surface:
- Every capability flag the host emits.
- Every lifecycle hook including failure paths.
- Routes covering every Section 6 step (public + private, validated + streaming, idempotent, rate-limited, RBAC-gated).
- Per-tenant AND global background jobs.
- Publishes and subscribes to events.
- Intentional schema-violation responses (behind
?failResponse=1) to integration-test response validation.
- Migrations spanning two schema versions to exercise upgrade-on-reload.
Full integration test runs against real Postgres + real Hono router as part of host CI — host change breaking a plugin contract makes the example fail loudly. Doubles as the canonical onboarding doc.
13.6 Pre-publish gate
Already in CLAUDE.md. Every package: lint typecheck test test:coverage green at 100%. Integration gated on INTEGRATION=1 — daily CI fast, nightly CI runs integration.
13.7 Scaffold
create-suaveplan-plugin <name> generates a working package with plugin.json skeleton, definePlugin skeleton, createTestHost test, integration test with testing-integration-postgres if DB selected, both vitest configs, migrations stub, README. Author runs bun test immediately, sees green, replaces skeleton — never starts from empty.
13.8 Out of scope
- Engine-specific edge cases (a plugin's Postgres migrations). The conformance suite tests the contract; the plugin's own integration tests cover its SQL.
- Cross-plugin interactions. If
billing subscribes to users.user.deleted, that integration is in billing's own integration test (booting users alongside), not in the host.
14. Open questions
Items that remain genuinely undecided and the implementation plan should resolve:
Item
Question
definePlugin typing mechanism
Branded as const literal arrays (Section 5.3) is the chosen direction — but the exact CapabilityMap lookup-type shape and how requires flows into RequestContext<Caps> needs spike at implementation time. Codegen remains the fallback if pure-TS inference can't deliver the ergonomics.
Bun ESM cache-bust correctness
The ?v=N query-suffix approach works in Bun today (Section 8.2). Implementation plan needs to verify behaviour under: HMR mode, bun build-emitted bundles, source-map preservation across reload, native binding detection accuracy. Fallback is a separate worker per generation if native cache-bust proves unreliable.
Saga step-replay semantics on host crash
When the host dies mid-step, on restart the orchestrator can re-issue the step. The contract says step handlers are idempotent — but what about step handlers that called a non-idempotent external API (e.g. Stripe POST /charges)? Three options: (a) leave it to plugin authors to deduplicate via Idempotency-Key; (b) host-issued idempotency key per {sagaId, stepName, attempt} injected into ctx; (c) "at-most-once" mode opt-in per step. Plan should pick.
2PC heuristic — when to refuse
Postgres can run thousands of prepared transactions but performance degrades. Plan needs a default cap on outstanding prepared transactions per coordinator and a back-off policy.
GraphQL schema collision resolution
Plan needs to decide whether graphql.namespace (wrapper-type approach) is the only solution, or whether name-mangling (auto-prefix with plugin name) is also offered.
WebSocket reconnect persistence
Reconnect tokens carry HMAC state — does the buffered event store (for resume) live in-memory only, in Redis, or pluggable? Default for v1 should be Redis when the broker is Redis-backed, in-memory single-host otherwise.
Worker thread library choice
Bun's Worker is the obvious pick. But child_process is also viable for stricter isolation (separate process, separate heap). Plan should decide based on benchmarks and on whether worker-vs-process matters to operators.
Sandboxed-tier WASI permission model granularity
WASI's permission model is at the file-descriptor / socket level. Plan needs to map our manifest's permissions.fs[] / permissions.fetch[] strings down to WASI preopens and capability descriptors. Reference impl probably constrains to "read-only fs preopens of listed paths + outbound socket allowlist."
Genuine non-goals (not deferred — explicitly out of scope):
Item
Why
Multi-region active-active host coordination
Cross-region consistency for the host metadata DB (plugin ledger, saga state, 2PC coordinator) is a separate design. The foundation assumes one host instance per region with eventual replication being a deployment concern.
Plugin marketplace / discovery service
The foundation supports third-party plugins via npm. A central marketplace + signing infra is its own product.
Built-in feature-flag system
@suaveplan/feature-flags already exists; plugins use it via the standard dep. Not a host capability.
Live schema migration across plugin versions
Forward-only migrations (Section 4) are the model. Plugin authors can write reversible migrations if they want, but the host doesn't enforce or run reversals.
15. Implementation plan — eight stages, all Wave 1
The foundation is one cohesive design. It lands as a single Wave with eight implementation stages, ordered by dependency direction so each stage can be tested against real adapters from the previous stage. No stage is "Wave 2" or "later" — every stage is v1 scope.
Stage 1 — Contracts
Pure type/contract packages with no runtime adapters. Locks the surface that every subsequent stage builds against.
api-types — every interface used downstream. Zero deps. Types-only build.
api-manifest — Zod schemas (manifest + companion schemas for config.schema.json, route schemas, saga schemas, WS/SSE schemas), validators, dep-graph + capability resolver.
api-router — abstract Router, Middleware, Request, Response, RouteOptions types; composition (prefix mounting, middleware chains, blue-green slot management interfaces).
api-graphql — abstract Registry, Resolver, LoaderFactory, federation contract.
api-ws + api-sse — abstract connection / subscription / broker contracts.
api-auth + api-auth-provider — abstract session-resolver and provider contracts.
api-secrets, api-tenant-provider — abstract contracts.
api-loader — module resolver + RollbackJournal interface (no implementations yet).
api-lifecycle — FSM definition (states + transitions), no concrete plugin yet.
Stage 2 — Host orchestration core
api-host — CapabilityRegistry, definePlugin, lifecycle driver, hot-reload primitive (Section 8), plugin ledger, admin API, error boundary, error envelope, OTel scaffolding.
- Persistent state: SQLite metadata DB schema for
suaveplan_plugin_ledger, suaveplan_rollback_journal, suaveplan_saga_instances, suaveplan_saga_steps, suaveplan_2pc_transactions.
At end of Stage 2, the host can boot but has zero functional adapters.
Stage 3 — Worker isolation infrastructure
api-worker-rpc — MessagePort protocol, correlation, AbortSignal forwarding, streaming, error reconstruction, backpressure.
api-worker-host — worker-side runtime hosting one plugin.
api-router-worker-bridge — request/response marshalling.
- Worker-side proxies:
api-db-worker-proxy, api-events-worker-proxy, api-secrets-worker-proxy, api-jobs-worker-proxy, api-logger-worker-proxy.
Conformance gates: every Stage-4 capability must run against both direct and worker-proxy surfaces.
Stage 4 — Base adapters (both surfaces)
api-router-hono — Hono adapter.
api-auth-jwt, api-auth-anonymous, api-auth-bearer-static.
api-tenant-resolver-jwt.
api-secrets-env.
api-db-adapter (bridge to @suaveplan/db, supporting Postgres, SQLite, Surreal).
api-tenant-provider-memory.
Stage 5 — Testing kit + conformance suites
- In-memory adapters:
api-router-in-memory, api-auth-static-principal, api-db-sqlite-memory, api-events-in-memory, api-ws-broker-in-memory.
- Conformance suites under
./testing subpaths on every abstract package: runRouterConformance, runAuthSessionConformance, runTenantResolverConformance, runDbAdapterConformance, runSecretsConformance, runEventsBusConformance, runJobsConformance, runGraphqlEngineConformance, runWsEngineConformance, runSseEngineConformance, runSagaParticipantConformance, run2pcParticipantConformance.
createTestHost from @suaveplan/api-host/testing.
End of Stage 5: every Stage-4 adapter passes its conformance suite under both isolation tiers.
Stage 6 — Distributed transactions + Better-Auth
api-saga — orchestrator, manifest-declared workflows, compensable steps, persisted state.
api-2pc — coordinator with prepared-transaction recovery on restart.
api-auth-better-auth — Better-Auth wraps both auth.session and auth.provider, owns its own DB connection.
Stage 6 is the first "real-world contract consumer" stage — sagas exercise event-bus + DB + idempotency together; Better-Auth exercises the auth contracts against a non-trivial library. Issues found in Stage 6 may surface design fixes to contracts from Stage 1–4; that's by design.
Stage 7 — GraphQL + WebSocket / SSE
api-graphql-yoga, api-graphql-apollo.
api-graphql-ui-graphiql, api-graphql-ui-altair.
api-ws-bun, api-ws-broker-redis.
api-sse-bun.
- Schema federation glue inside
api-graphql.
- Subscription bus + broker abstraction inside
api-ws.
Stage 8 — Sandboxing + cross-cutting middleware + authoring helpers
api-sandbox-permissions — permission model.
api-sandbox-vm — vm.Context-based sandbox.
api-sandbox-wasm — WASM/WASI target.
api-rbac, api-rate-limit.
api-openapi + api-openapi-ui-{scalar,swagger,redoc}.
api-audit-stdout, api-audit-file.
api-auth-rolled — reference rolled-our-own auth.
api-secrets-bun, api-secrets-vault.
api-tenant-resolver-header, api-tenant-resolver-subdomain.
api-plugin-example — exercises every capability across every isolation tier. Load-bearing integration test.
create-suaveplan-plugin — scaffold via @suaveplan/installer.
End of Stage 8: foundation complete. Existing @suaveplan/api-schemas and @suaveplan/sitemap either migrate to live as plugins (if they want lifecycle hooks) or stay as pure libraries consumed by plugins.
Per-package quality gate
Every package in every stage goes through the standard genesis pre-publish gate: lint typecheck test test:coverage green at 100%, all files within the 300-line hard cap, co-located .md per implementation file, tests imported from @suaveplan/testing/runner, no vi.mock anywhere.
What "done" looks like
- Reference plugin (
api-plugin-example) deploys cleanly against all three isolation tiers (trusted, isolated, sandboxed).
- Reference plugin exercises: REST routes (public + private + streaming + idempotent + rate-limited + RBAC-gated), GraphQL types + resolvers + subscriptions, WebSocket endpoint with subscription fan-out, SSE endpoint, owned saga + 2PC participation, scheduled + per-tenant cron jobs, queued jobs, event publish + subscribe, custom error codes with manifest-mapped statuses, custom metrics, multiple secrets, schema migrations across two
schemaVersion bumps with auto-rollback test.
- Better-Auth adapter deploys against the reference plugin's auth needs end-to-end, including OAuth flow with at least one provider (e.g. GitHub).
- Full hot-reload cycle exercised: file-watch in dev triggers blue-green swap, in-flight requests complete on old slot, new slot health probe passes, traffic flips. Bad-module path exercised: failed
start triggers auto-rollback, plugin enters rolled_back, prior version keeps serving.
- Admin API can install / uninstall / disable / enable / reload / rollback / force-uninstall a plugin end-to-end with audit log entries for each.
- Conformance suites pass against every adapter under both direct and worker-proxy surfaces.