Plugin contract

definePlugin, lifecycle hooks, rpc interceptors, framework-version compatibility — the surface every Voltro plugin implements.

A plugin is the unit of cross-cutting framework extension. Examples in the wild:

  • @voltro/plugin-audit records every mutation invocation to a sink.
  • @voltro/plugin-multitenancy adds the tenant() schema mixin + write-guard.
  • @voltro/plugin-webhooks ships incoming + outgoing webhook tables + workflow.
  • @voltro/plugin-auth-workos (and 5 siblings) plug an external IdP into the auth chain.

Plugins live as npm packages, get listed in app.config.ts's plugins: array, and the framework wires their hooks at boot. The contract is intentionally narrow — a plugin is NOT a full app extension; it's a focused cross-cutting concern that pairs with the existing query / mutation / action / workflow primitives.

The shape

import { definePlugin } from '@voltro/protocol'

export const myPlugin = (options: MyOptions) =>
  definePlugin({
    name: '@vendor/my-plugin',         // stable id — surfaced in boot logs + dashboard
    description: 'rate-limits outbound HTTP per tenant',
    framework: '^1.0.0',                // semver range — soft-checked at boot
    interceptMutation: async (next, ctx) => { /* wraps every mutation */ },
    interceptAction:   async (next, ctx) => { /* wraps every action */ },
    onActivate:        (lifecycle) => { /* one-shot boot: open pools, register metrics */ },
    onDeactivate:      (lifecycle) => { /* graceful shutdown */ },
  })

definePlugin is an identity function with type-level enforcement — it returns the input unchanged at runtime, but the type-checker catches missing required fields, misnamed hooks, and excess properties at the declaration site. Always use it over plain object literals.

Hooks

interceptMutation / interceptAction (Effect-native)

Wrap every mutation or action call respectively. Both are Effect-based — the framework is Effect end-to-end and the plugin boundary preserves that shape so tracing, interruption, and the typed error channel flow through without round-tripping to Promise:

type RpcInterceptor = (
  next: Effect.Effect<unknown, unknown>,
  context: {
    readonly tag: string                    // 'todos.create', 'support.ping', …
    readonly kind: 'mutation' | 'action'    // discriminator
    readonly input: unknown                 // the validated payload
    readonly subject: Subject               // resolved by AuthMiddleware
    readonly traceId: string                // matches OTel spans + logs
  },
) => Effect.Effect<unknown, unknown>

The interceptor MUST flow next through somehow — yield it, pipe through it, or return it. Whatever the returned Effect produces becomes the final result UNLESS the interceptor substitutes a different one (cache hits, etc). Failing the returned Effect skips the rest of the chain and surfaces the error to the rpc layer.

// Pre-only: short-circuit before the executor runs.
const guard: RpcInterceptor = (next, ctx) =>
  ctx.tag.startsWith('admin.') && ctx.subject.type !== 'user'
    ? Effect.fail(new ScopeError({ required: 'user', message: ` is admin-only` }))
    : next

// Post-only: tap the success/failure channels.
const log: RpcInterceptor = (next, ctx) =>
  next.pipe(
    Effect.tap((result) => recordAudit(ctx.tag, ctx.input, result, ctx.subject)),
    Effect.tapError((err) => recordAuditError(ctx.tag, err)),
  )

// Tracing: spans nest automatically.
const trace: RpcInterceptor = (next, ctx) =>
  next.pipe(Effect.withSpan(`plugin.${ctx.tag}`))

Composition. When multiple plugins each install interceptMutation, the framework composes them in declaration order: the first plugin in app.config.ts's plugins: array is the OUTERMOST wrapper, the last is closest to the executor. That mirrors how middleware composition normally reads top-to-bottom in user code.

Why three hooks instead of one. A plugin that only wants to govern outbound HTTP (rate limiting, tenant-scoped IO quotas) installs interceptAction without touching mutations. A plugin that records writes (audit log) installs interceptMutation without touching reads or actions. The split lets plugins opt in narrowly.

interceptQuery wraps subscription setup — the one-time call that produces the QueryDescriptor. Use it for pre-setup authz (deny before the subscription opens), per-tenant subscription-rate-limiting, or subscription-open audit logging. It does NOT wrap every snapshot/delta delivery — per-delta observability flows through the framework's OTel spans (subscription.<tag>.snapshot / .delta); wrapping every delivery would add per-event latency the streaming model is specifically designed to avoid.

interceptQuery: (next, ctx) =>
  ctx.subject.type === 'anonymous' && ctx.tag.startsWith('admin.')
    ? Effect.fail(new Unauthenticated({ reason: 'admin queries require authentication' }))
    : next,

A failure inside an interceptQuery aborts the subscription open; the rpc layer surfaces it to the client as a subscription error. Substituting the return Effect is supported but rare — usually interceptors do pre-only or post-only side effects.

Per-plugin observability is automatic. Every interceptor is wrapped at compose time with Effect.withSpan('plugin.<name>.intercept-<kind>') + a plugin.* metric sample — no opt-in. The DevTools / cloud dashboard's Plugins panel filters /_voltro/inspect/metrics by the plugin.* prefix to render per-plugin latency + count buckets, and the trace waterfall shows the plugin layer as a nested span inside the handler boundary.

onChangeEvent — the post-commit ChangeEvent tap

onChangeEvent: (event: PluginChangeEvent) => Effect.Effect<void, unknown>
// PluginChangeEvent = {
//   table, op: 'insert'|'update'|'delete', new: Row | null, old: Row | null,
//   origin?: 'inline' | 'injected', changeScope: 'local' | 'fleet',
// }

A tap on the store's post-commit change stream — the plugin sees every committed insert/update/delete. It returns an Effect<void, E> that the runtime supervises: it forks the Effect under the plugin's supervision scope (so the tap is non-blocking and can never back-pressure the change stream or the writing mutation) and routes the Effect's failure channel to the plugin-scoped logger. That gives a change-tap a real, typed error channel — Effect.retry, Effect.timeout, Effect.catchTag, a durable enqueue — instead of fire-and-forget glue.

onChangeEvent: (event) =>
  event.table !== 'orders'
    ? Effect.void                                   // not my table — no-op
    : Effect.tryPromise(() => mirror(event)).pipe(  // failure is logged by the runtime
        Effect.retry({ times: 3 }),
      )

Runs under BOTH voltro dev and voltro serve (the prod serve path fans out the same way). Requires the 'store:changes:read' permission. It is NOT durable at the framework layer — a crash between commit and the fork loses the event; build durability INSIDE the Effect (insert into an outbox and retry against the typed error channel, the way @voltro/plugin-cdc-out does). Exactly-once / change-scope semantics are unchanged: read event.origin + event.changeScope inside the Effect to act once per change fleet-wide (skip origin: 'injected' on 'local' scope; elect one worker on 'fleet'). Used by @voltro/plugin-search to mirror rows into an external index. For in-transaction reactions use a mutation; for best-effort per-table reactions in app code prefer a *.subscribe.tsonChangeEvent is the plugin-level equivalent.

onInstall / onUninstall / onActivate / onDeactivate lifecycle

Four lifecycle hooks, all accepting Effect / Promise / sync return values. The framework awaits each in turn — sync returns settle synchronously, Promises and Effects are awaited.

interface PluginLifecycleContext {
  readonly app: {
    readonly name: string
    readonly type: 'api' | 'web'
    readonly voltroVersion: string
  }
  readonly logger: { info; warn; error; debug }   // scoped to the plugin's name
  readonly env: NodeJS.ProcessEnv
  readonly config: unknown                         // validated against configSchema if declared
}
  • onInstall runs ONCE per process per plugin@version — the FIRST time the host sees this plugin. Use for schema migrations + resource provisioning that survive across deactivate/activate cycles. In v1 the install marker is in-memory; persisted state (_voltro_plugin_installs table) lands with the migration runner. Failure aborts boot.
  • onUninstall is the teardown mirror of onInstall — declare it to drop tables, delete queues, etc. when the plugin is removed from the app. Keep it idempotent (re-running is a no-op once torn down).
  • onActivate runs every boot. Use for warming caches, opening connection pools, registering metrics sinks. The framework awaits each hook sequentially in declaration order. Throwing/failing aborts boot — better to surface a misconfigured plugin loudly than start the rpc server with half-wired plugins.
  • onDeactivate runs in REVERSE declaration order (mirror of activate — the most-recently-activated plugin tears down first). Each hook has a 5-second per-plugin grace window and the whole sequence is capped at 30 seconds total. A plugin that throws or exceeds its grace window logs a warning + the sequence moves on; shutdown never blocks on a stuck plugin. Idempotent — repeated SIGTERM doesn't re-run hooks. Hookup covers SIGINT + SIGTERM in both voltro dev and voltro start.

framework compatibility

definePlugin({
  name: '@vendor/x',
  framework: '^1.0.0',     // accepts 1.x, rejects 0.x and 2.x
  // OR: '~1.2.0', '>=1.0.0 <2.0.0', '1.2.3' (exact), '*' (any)
})

At boot the framework runs checkFrameworkCompat(plugin.name, plugin.framework, runningVoltroVersion). Mismatch logs a WARN with the constraint + the running version, and boot continues by default — the operator decides whether to pin a different version or upgrade. Set VOLTRO_STRICT_PLUGIN_COMPAT=1 to make a mismatch a hard boot-abort instead (the plugin throws at boot) — for CI or regulated deployments that must refuse to run a plugin built against a different framework version.

Absent framework field → no compat check. Suitable for in-tree plugins that ship lockstep with the framework.

What plugins CAN'T do (initial policy)

  • Mutate other plugins' state. Plugins don't talk to each other directly. If two plugins need to coordinate, it's via the rpc layer (one plugin's interceptor sees the other's subject.metadata, for example).
  • Bypass tenant scoping. Interceptors run AFTER the runtime's tenant predicate merge. A mutation interceptor can't query rows from another tenant by manipulating subject.tenantId.
  • Access raw secrets directly. Plugins get config via their own factory function's options object. lifecycle.env exposes process.env but only the plugin's own factory chooses which env vars to read.
  • Modify the core schema DSL or query builder. Schema extension happens through schema mixins (the *.mixin.ts pattern in @voltro/database); not through plugin runtime hooks.

These boundaries hold for v1; some may relax for verified plugins once the marketplace ships.

Stacking

// app.config.ts
import { auditPlugin }       from '@voltro/plugin-audit'
import { rateLimitPlugin }   from '@vendor/rate-limit'
import { metricsPlugin }     from '@vendor/metrics'

export default {
  type: 'api' as const,
  name: 'myApi',
  plugins: [
    metricsPlugin({ sink: 'datadog' }),    // OUTERMOST — sees every request first
    rateLimitPlugin({ perTenant: 100 }),
    auditPlugin({ sink: 'console' }),       // INNERMOST — closest to executor
  ],
}

The first plugin's interceptMutation wraps the second's, which wraps the third's, which wraps the executor. Same composition for interceptAction. The chain runs in deterministic order independent of import order or filesystem walk — only plugins: array order matters.

services: Layer — contribute Tags into the per-request runtime

A plugin can provide an Effect Layer whose Tags become available to EVERY handler in the app. Handlers yield* MyTag to read; plugins own the implementation; consumers don't import the plugin directly.

import { Effect, Layer } from 'effect'
import { definePlugin, definePluginService } from '@voltro/protocol'

interface AuditService {
  readonly record: (event: { tag: string; actor: string }) => Effect.Effect<void>
}

const { Tag: Audit, Live: AuditLive } = definePluginService<AuditService, AuditService>(
  '@vendor/audit/Service',
  {
    record: (event) => Effect.sync(() => console.log('AUDIT', event)),
  },
)

export const auditPlugin = (): VoltroPlugin =>
  definePlugin({
    name: '@vendor/audit',
    services: AuditLive,
  })

// In any handler:
//   import { Audit } from '@vendor/audit'
//   const audit = yield* Audit
//   yield* audit.record({ tag: 'todos.create', actor: 'u_42' })

Layer composition is independent of declaration order across plugins (Effect's mergeAll is commutative on Tag identity); collisions on the SAME Tag resolve to the LAST layer in the merge list, so a user layer in apiConfig.layers can override a plugin layer with the same Tag.

routes: PluginRpcRoute[] — plugins ship their own RPC endpoints

Plugins can register their own queries / mutations / actions alongside user-authored queries. Same wire protocol, same dashboard surface, same interceptor + tracing wiring. Query tags carry a plugin-derived prefix so plugin queries never collide with user queries:

import { definePlugin, definePluginRoute } from '@voltro/protocol'
import { Effect, Schema } from 'effect'

export const auditPlugin = (): VoltroPlugin =>
  definePlugin({
    name: '@voltro/plugin-audit',
    routes: [
      definePluginRoute({
        kind: 'query',
        name: 'list',                       // tag = 'audit.list' (plugin alias prepended)
        input: Schema.Struct({ limit: Schema.optional(Schema.Number) }),
        output: Schema.Array(AuditEventSchema),
        execute: (input) => Effect.gen(function* () {
          const buffer = yield* AuditBuffer    // service Tag from plugin's `services` layer
          return buffer.read(input.limit ?? 100)
        }),
      }),
      definePluginRoute({
        kind: 'mutation',
        name: 'clear',                      // tag = 'audit.clear'
        input: Schema.Struct({}),
        output: Schema.Struct({ cleared: Schema.Number }),
        execute: () => Effect.sync(() => {
          // clear logic — runs inside the framework's transactional wrap
          return { cleared: 0 }
        }),
      }),
    ],
  })

Tag derivation: <plugin-alias>.<query.name> unless query.name contains a dot (escape hatch). Plugin alias strips @scope/ + the plugin- prefix and kebab→camelCase:

Plugin name Alias
@voltro/plugin-audit audit
@scope/plugin-rateLimit rateLimit
@scope/plugin-rate-limit rateLimit
plain-name plainName
@voltro/audit audit (no plugin- to strip)

Boot fails with a clear error on tag collisions (between two plugins, or with a user-authored tag).

Reactive plugin queries — source

A plugin query can be push-driven instead of poll-only. Add a source table (or tables) to a kind: 'query' route and the framework re-runs the executor and pushes a fresh result over the SAME subscription/WS transport app reactive queries use — every time one of those tables changes. No new push system: it reuses the framework's computed-reactive-query machinery, so the client just subscribes and receives live updates.

definePluginRoute({
  kind: 'query',
  name: 'list',
  source: '_voltro_presence',            // ← reactive: re-run + push on any change to this table
  input: Schema.Struct({ channel: Schema.String }),
  output: Schema.Array(MemberSchema),
  // The executor returns the query's VALUE (the same shape it returns when
  // polled); the framework recomputes + pushes it on change.
  execute: (input, ctx) => Effect.gen(function* () {
    // read the table, shape the roster, return it
    return roster
  }),
})

source must name a table declared .reactive(). Omit source for a plain poll-only plugin query, a mutation, or an action. This is exactly how @voltro/plugin-presence makes presence.list push-driven — the usePresence roster updates live with no client polling.

permissions: PluginPermission[] + configSchema: Schema — manifest fields

definePlugin({
  name: '@vendor/audit',
  version: '1.2.3',
  permissions: [
    'rpc:intercept:mutation',                   // matches interceptMutation
    'rpc:intercept:query',                      // matches interceptQuery
    'secrets:read:audit:*',                     // pattern-perm (runtime check)
  ],
  configSchema: Schema.Struct({
    sink: Schema.Literal('console', 'memory', 'postgres'),
    verbose: Schema.optional(Schema.Boolean),
  }),
})

permissions is a typed enum. The framework audits every plugin's declared set against its hook surfaces at boot — a plugin that ships onHttpRequest without declaring 'http:intercept' (or any other hook-without-perm combination) fails boot with the specific scope name. No silent strip; no advisory warnings; the operator either grants the perm or the plugin doesn't run.

The static surface is a union of well-known scopes; pattern perms ('secrets:read:foo:*', 'network:outbound:api.stripe.com', 'plugin:hook:<other-plugin>') fall through the template-literal arm. Runtime resource checks use permissionMatches(declared, required) — a declared 'secrets:read:auth0:*' covers a required 'secrets:read:auth0:clientSecret'.

Hook surface Required permission
interceptMutation 'rpc:intercept:mutation'
interceptQuery 'rpc:intercept:query'
interceptAction 'rpc:intercept:action'
onScheduleFire 'schedule:fire'
onWorkflowStep 'workflow:step'
onHttpRequest 'http:intercept'
onChangeEvent 'store:changes:read'
inspectEndpoints (non-empty) 'inspect:read'
dashboard (non-empty) 'dashboard:mount'
extendSchema.tables (non-empty) 'store:write'
extendSchema.migrations (non-empty) 'migration:run'

configSchema decodes the operator's user-supplied config payload at boot. Decode failures abort boot with a typed error pointing at the plugin. The decoded value lands in PluginLifecycleContext.config so lifecycle hooks consume the already-validated shape.

Writing a plugin — minimal example

// packages/plugin-rate-limit/src/index.ts
import { Effect } from 'effect'
import { definePlugin, type VoltroPlugin } from '@voltro/protocol'

interface RateLimitOptions {
  readonly perTenant: number      // requests/minute
}

const buckets = new Map<string, { count: number; windowStart: number }>()

export const rateLimitPlugin = (options: RateLimitOptions): VoltroPlugin =>
  definePlugin({
    name: '@vendor/rate-limit',
    framework: '^1.0.0',
    interceptMutation: (next, ctx) => {
      if (ctx.subject.type === 'anonymous') return next  // no per-tenant limit
      const key = ctx.subject.tenantId
      const now = Date.now()
      const bucket = buckets.get(key) ?? { count: 0, windowStart: now }
      if (now - bucket.windowStart > 60_000) {
        bucket.count = 0
        bucket.windowStart = now
      }
      bucket.count++
      buckets.set(key, bucket)
      if (bucket.count > options.perTenant) {
        return Effect.fail(new Error(`rate limit exceeded for tenant ${key}`))
      }
      return next
    },
    onActivate: ({ logger }) => {
      logger.info('rate-limit plugin armed', { perTenant: options.perTenant })
    },
  })

That's the whole plugin. Drop it in app.config.ts, voltro dev picks it up, every mutation goes through the rate limiter.

Schema mixins — the OTHER plugin shape

Not every cross-cutting concern needs runtime hooks. Schema extensions live in their own surface — defineMixin({...}) from @voltro/database — and don't go through the VoltroPlugin contract at all. See database/mixins for that pattern.

A package can ship BOTH a schema mixin AND a runtime plugin (@voltro/plugin-audit does — the audit() mixin adds columns; the auditPlugin() factory installs the mutation interceptor). They're independent — apps that want the schema but not the interceptor .with(audit()) without plugins: [auditPlugin()], and vice versa.

More extension surfaces

The plugin contract carries eight additional surfaces beyond the interceptors + lifecycle + manifest fields covered above:

inspectEndpoints: PluginInspectEndpoint[] — plugin-mounted HTTP endpoints

import { definePlugin } from '@voltro/protocol'
import { Effect } from 'effect'

export const auditDebugPlugin = (): VoltroPlugin =>
  definePlugin({
    name: '@voltro/plugin-audit-debug',
    inspectEndpoints: [
      {
        method: 'GET',
        path: 'buffer',                            // → /_voltro/inspect/plugins/auditDebug/buffer
        description: 'Returns the last 1000 audit events',
        handler: () => Effect.sync(() => ({
          kind: 'json',
          data: { events: readAuditBuffer() },
        })),
      },
      {
        method: 'POST',
        path: 'clear',
        handler: () => Effect.sync(() => {
          clearAuditBuffer()
          return { kind: 'json' as const, data: { cleared: true } }
        }),
      },
    ],
  })

Plugin endpoints inherit the framework's auth resolver — plugins can't bypass VOLTRO_INSPECT_TOKEN. Path: /_voltro/inspect/plugins/<plugin-alias>/<path>. Boot fails loudly on collisions (two plugins claiming the same path+method).

onScheduleFire: ScheduleFireInterceptor — wrap every cron firing

definePlugin({
  name: '@vendor/schedule-gate',
  onScheduleFire: async (next, ctx) => {
    // Suppress all firings of `nightlyBilling` when the kill-switch is on.
    if (ctx.name === 'nightlyBilling' && (await isKilled('billing'))) {
      return  // skip — handler doesn't run
    }
    await next()
  },
})

Composed across plugins in declaration order; runs INSIDE the schedule's maxRuntimeMs watchdog. Per-plugin span (plugin.<name>.schedule-fire)

  • metric auto-emitted.

onWorkflowStep: WorkflowStepInterceptor — wrap every step()

import { Effect } from 'effect'

definePlugin({
  name: '@vendor/step-observer',
  onWorkflowStep: (next, ctx) =>
    next.pipe(
      Effect.withSpan(`vendor.workflow.${ctx.stepName}`, {
        attributes: { 'workflow.attempt': ctx.attempt },
      }),
      Effect.tap((output) => Effect.sync(() => recordStep(ctx, output))),
    ),
})

Wraps every step() (== Activity.make) inside a workflow body. Effect- native — the interceptor sees next as an Effect and can pipe through tap/retry/withSpan. Bypassed on workflow REPLAY — the interceptor only fires on the first execution per step; on resume after a crash, the cached Activity output is replayed without re-running the user effect or the plugin interceptor.

codegen: PluginCodegen — emit typed bindings into rpcGroup.generated.ts

definePlugin({
  name: '@voltro/plugin-audit',
  codegen: (ctx) => `
// Typed accessor for the audit plugin's inspect endpoint
export const useAuditBuffer = () =>
  fetch('/_voltro/inspect/plugins/${ctx.pluginAlias}/buffer')
    .then((r) => r.json() as Promise<{ events: ReadonlyArray<AuditEvent> }>)
`,
})

The framework emits the returned string into rpcGroup.generated.ts between // <plugin:@voltro/plugin-audit> / // </plugin:@voltro/plugin-audit> markers. The plugin sees the api name + the list of discovered user-query rpc tags so it can emit per-rpc bindings. Returning null contributes nothing.

templates: PluginTemplate[] — ship voltro init templates

definePlugin({
  name: '@vendor/plugin-stripe-webhooks',
  templates: [
    {
      id: 'stripe-receiver',
      title: 'Stripe webhook receiver',
      description: 'Verified Stripe-webhook endpoint with replay protection',
      kind: 'api',
      sourcePath: 'templates/stripe-receiver',
      postInstallSteps: [
        'Set VOLTRO_WEBHOOK_SECRET_STRIPE in your env',
        'Configure the endpoint URL in your Stripe dashboard',
      ],
    },
  ],
})

Templates surface under the plugin alias in voltro list-templates. The registration shape + manifest are part of the public contract, and a plugin-provided template is scaffolded exactly like a built-in one — scaffoldFromTemplate copies the declared tree and substitutes the {{…}} tokens in both file content AND file/directory names.

onHttpRequest: HttpRequestInterceptor — pre-auth HTTP-pipeline hook

Fires at the very top of every HTTP request — BEFORE auth resolution, BEFORE rpc routing, BEFORE inspect. Composed across plugins in declaration order (first listed = outermost). Returning a HttpInterceptResponse short-circuits the pipeline; returning null (or calling next() and returning its result) lets the request flow through to the framework's normal handlers.

This is not an auth replacement — AuthMiddleware still runs on the rpc path. Use this hook for concerns that need to fire BEFORE auth: rate-limit, geo-block, bot detection, header injection for downstream observability. Requires the 'http:intercept' permission.

import { definePlugin } from '@voltro/protocol'

export const rateLimitPlugin = (opts: { perMinute: number }) =>
  definePlugin({
    name: '@vendor/plugin-rate-limit',
    permissions: ['http:intercept'],
    onHttpRequest: async (next, ctx) => {
      const remoteAddr = ctx.headers['x-forwarded-for'] ?? ctx.remoteAddr ?? 'unknown'
      if (buckets.consume(remoteAddr, opts.perMinute) === 'exhausted') {
        return {
          status: 429,
          headers: { 'retry-after': '60', 'content-type': 'application/json' },
          body: JSON.stringify({ error: 'rate_limit_exceeded' }),
        }
      }
      return next()                              // let the request continue
    },
  })

A per-plugin plugin.<name>.http-intercept metric is auto-emitted so the dashboard's Plugins panel surfaces HTTP-intercept latency next to RPC-intercept latency.

extendSchema: { tables, migrations } — contribute schema + migrations

A plugin contributes BOTH declarative table descriptors AND custom SQL migrations. Tables merge into the user's schema and flow through the same idempotent applySchema() path. Migrations run after applySchema against the live SqlClient and are tracked in _voltro_plugin_migrations so each runs exactly once per app database.

The ledger key is <plugin-alias>__<migration.id> so two plugins can each ship '001-init' without collision. Failure aborts boot; re-runs are no-ops.

import { Effect, Schema } from 'effect'
import { definePlugin } from '@voltro/protocol'
import { table, id, text, timestamp } from '@voltro/database'

const auditLogs = table('audit_logs', {
  id:         id({ prefix: 'audit' }),
  actorId:    text(),
  action:     text(),
  payload:    text(),
  createdAt:  timestamp().default('now'),
})

export const auditPlugin = () =>
  definePlugin({
    name: '@voltro/plugin-audit',
    permissions: ['store:write', 'migration:run', 'rpc:intercept:mutation'],
    extendSchema: {
      tables: [auditLogs],
      migrations: [
        {
          id: '001-pgcrypto-extension',
          description: 'enable pgcrypto for hashing actor ids',
          up: (sql) => Effect.gen(function* () {
            yield* sql`CREATE EXTENSION IF NOT EXISTS pgcrypto;`.pipe(Effect.orDie)
          }),
        },
      ],
    },
  })

tables requires 'store:write'; migrations requires 'migration:run'. Either one missing → boot fails with the specific permission name.

dashboard: PluginDashboardMount[] — remote-mounted dashboard surfaces

Plugins surface UI in the cloud / devtools dashboard by declaring remote-loaded ESM modules. The host runtime fetches the bundle URL at mount time, dynamic-imports it, and renders the exported component inline (NOT iframe — true in-process mount).

Trade-offs vs iframe:

  • The plugin's bundle MUST be ESM with React/Effect as peerDeps; the host pins versions. Mismatch logs a warning at mount time but still mounts.
  • No CSS isolation. Plugin authors are expected to use scoped Tailwind classes (the dashboard ships the framework token set) or CSS modules.
  • Plugin code shares the host JS realm — honor-system sandboxing. The dashboard:mount permission gate keeps the operator audit trail explicit; signing/marketplace verification is the longer-term answer.
definePlugin({
  name: '@vendor/plugin-audit',
  permissions: ['dashboard:mount'],
  dashboard: [
    {
      id: 'overview',
      slot: 'page',                                 // 'page' | 'widget' | 'nav'
      route: 'overview',                            // mounted at /plugins/audit/overview
      label: 'Audit log',
      icon: 'shield',                               // optional lucide-react icon name
      bundleUrl: 'https://cdn.example.com/audit/v1/dashboard.mjs',
      exportName: 'AuditOverview',                  // optional; defaults to 'default'
      dashboardVersion: '^1.0.0',                   // optional compat range
    },
    {
      id: 'recent-events',
      slot: 'widget',                               // card on the dashboard home
      label: 'Recent audit events',
      bundleUrl: 'https://cdn.example.com/audit/v1/widget.mjs',
    },
  ],
})

The registry surfaces at /_voltro/inspect/plugins/dashboard-mounts:

curl http://localhost:4000/_voltro/inspect/plugins/dashboard-mounts | jq
# { "mounts": [...], "capturedAt": 1717142400000 }

Boot validates within-plugin id uniqueness AND that slot: 'page' mounts carry a route. Cross-plugin ids are namespaced as <plugin-alias>:<mount.id> (e.g. audit:overview).

The actual dashboard-host runtime — the React shell that dynamic-imports

  • mounts the component — lives in voltro-cloud-dashboard + voltro-devtools. The framework owns the registration shape + manifest; the consumers own the renderer.

bindDataStore(store, ctx) — bind DB-backed resources + framework handles

onActivate runs BEFORE the app's DataStore exists (the framework activates plugins, THEN opens the store). bindDataStore is the post-store hook: the framework calls it once, after the store + pool are open, so a plugin can bind a resource it couldn't build from static config — e.g. swap an in-memory ref store for a DataStore-backed one.

The second argument, ctx: PluginBindContext, hands the plugin the framework's ALREADY-OPEN handles so it never rebuilds them:

  • ctx.sql — the framework's live SqlClient (from @effect/sql), the SAME pool the app's store uses. Run raw SQL through it instead of standing up your OWN ManagedRuntime + pool from env. undefined on the in-memory store (no SQL engine) — guard with if (ctx.sql).
  • ctx.scheduleCoordinated(name, intervalMs, effect) — run a periodic task on ONLY ONE replica per tick, cluster-coordinated via the same claim-table exactly-once gate the cron scheduler uses. Replaces the hand-rolled setInterval a plugin would otherwise run inside bindDataStore — which fires on EVERY replica, so a 10-pod deployment runs the same full-table sweep 10× per interval. On a single-process / memory / sqlite deployment it simply runs every tick locally (correct — one process needs no fan-out dedup). Returns a handle whose stop() cancels the task; the framework also stops every armed task at shutdown.
import { definePlugin } from '@voltro/protocol'

definePlugin({
  name: '@vendor/plugin-presence',
  permissions: ['store:write'],
  bindDataStore: (store, ctx) => {
    // Reuse the framework's open pool — no connFromEnv, no second pool.
    if (ctx?.sql) {
      // ctx.sql is the app's live SqlClient (Effect-native).
    }
    // One coordinated sweep fleet-wide, not one setInterval per replica.
    ctx?.scheduleCoordinated('presence.sweep', 60_000, async () => {
      await store.deleteMany('_voltro_presence', {
        where: { column: 'lastSeen', op: 'lt', value: Date.now() - 120_000 },
      })
    })
  },
})

store is typed as the framework DataStore; ctx is optional in the type (the framework always passes it) so an older single-argument bindDataStore(store) still compiles. Requires no extra permission beyond whatever the store operations themselves need (store:write for writes).

Inspecting plugins at runtime

Every running app exposes its plugin manifest at /_voltro/inspect/plugins:

curl http://localhost:4000/_voltro/inspect/plugins | jq

Returns one entry per plugin in app.config.ts's plugins: array with:

  • name, version, description, framework semver range
  • permissions: string[] — declared scopes
  • hooks — which interceptor + lifecycle hooks the plugin installed
  • querieskind:name tuples for every plugin-contributed query
  • services: boolean — whether the plugin contributes a service layer
  • hasConfigSchema: boolean — whether configSchema is declared
  • activated, installed — runtime state

The DevTools + cloud dashboard's Plugins panel consumes this; AI agents grep it to answer "what is this deployment running, with what permissions".

Per-plugin metrics surface under the SAME /_voltro/inspect/metrics endpoint that carries rpc.* / request.* buckets — plugin buckets have kind: 'plugin' and tag plugin.<name>.intercept-<mutation|query|action>. Filter client-side by the plugin. prefix to render the per-plugin latency table.

Building integration plugins — @voltro/integration-http

A plugin that talks to a third-party REST API (Jira, GitHub, GitLab, …) needs the same server-side transport concerns every time: token auth, transient retry with backoff, per-request timeout, an SSRF host guard, and typed errors. @voltro/integration-http is that transport core — one implementation shared by @voltro/plugin-atlassian and any integration plugin you write, so you don't re-invent it.

makeHttpClient binds a base URL, an auth strategy, a retry/timeout policy, and a fetch impl once, then hands you typed request methods. Every method returns Effect.Effect<A, YourError> — failures land on your error channel via the makeError you supply, so each integration keeps its own Schema.TaggedError:

import { makeHttpClient, type FetchLike } from '@voltro/integration-http'
import { Schema } from 'effect'

class GithubError extends Schema.TaggedError<GithubError>()('GithubError', {
  message: Schema.String,
  transient: Schema.Boolean,
  status: Schema.optional(Schema.Number),
  code: Schema.optional(Schema.Literal('session_expired')),
}) {}

const github = makeHttpClient<GithubError>({
  baseUrl: 'https://api.github.com',
  auth: () => ({
    authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
    accept: 'application/vnd.github+json',
  }),
  makeError: (a) => new GithubError(a),
  fetchImpl: fetch as unknown as FetchLike,
})

// Each method returns Effect.Effect<…, GithubError>.
const repo = yield* github.getJson('/repos/acme/widgets')
const created = yield* github.postJson('/repos/acme/widgets/issues', {
  body: { title: 'bug', body: 'it broke' },
})

What the core does for you: retries 408/425/429/5xx with capped exponential backoff (jittered, honouring Retry-After within the maxDelayMs ceiling), fails a hanging request as transient after the timeout, pins every request to the base URL's host (an off-host or malformed URL is rejected before any fetch, fail-closed), maps a 401 to a non-transient session_expired (re-auth, never retried), and wraps each request in an integration-http.request span (method + host only — never a token). When the token is resolved per request (per-subject credentials), give the client a context type and pass ctx on each call:

const client = makeHttpClient<GithubError, { token: string }>({
  baseUrl: 'https://api.github.com',
  auth: (ctx) => ({ authorization: `Bearer ${ctx.token}` }),
  makeError: (a) => new GithubError(a),
  fetchImpl: fetch as unknown as FetchLike,
})

const me = yield* client.getJson('/user', { ctx: { token: perSubjectToken } })

Server-side only. This module performs outbound network I/O — never import it (or a module that imports it) from a browser-loaded query / mutation / action / workflow descriptor.

  • Schema mixins — the OTHER plugin shape (schema-only, no runtime hooks).
  • Auth strategies — a specialised plugin pattern for identity providers.