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',
//   traceId?, subjectId?, procedure?,
//   oversized?: 'rehydrated' | 'tombstone' | 'unrecovered',
// }

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 }),
      )

Check event.oversized before you treat an image as a snapshot. It is absent on an ordinary event, and set when the transport could not carry the row and the images were reconstructed — a row over postgres' 8000-byte NOTIFY cap. 'rehydrated' means new is the row re-read from the database (correct to index or forward, but the row as it is NOW rather than the image at commit); 'tombstone' means a delete whose old is the primary key and nothing else (enough to REMOVE the row, never a record of what it held); 'unrecovered' means both images are null and the content is gone. A tap that stores history must not write a tombstone as a snapshot. Full guarantee: postgres — oversized rows.

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 (defineMixin from @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>. The 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)

A query.name that already contains a dot is handled by whether it names the plugin's OWN namespace:

  • 'notifications.inbox' on a plugin whose canonical name is @voltro/plugin-notifications is re-namespaced — under alias: 'inbox' it becomes inbox.inbox, not notifications.inbox. A deeper path keeps its depth: 'audit.admin.events' under alias: 'trail' becomes trail.admin.events.
  • 'acme.legacyBridge' — a namespace that is not the plugin's own — passes through untouched. That is the escape hatch, and it is the only case that still bypasses the alias.

The re-namespacing needs the plugin to declare baseName (its canonical name, before any app-supplied alias); a plugin that omits it keeps the older behaviour where any dotted name passes through.

Naming a plugin: alias vs name

Two different app-side problems land on a plugin's name, so first-party plugins that carry tables or routes accept two separate options:

Option Question it answers Effect
alias "your namespace collides with mine" replaces the namespace — tags become <alias>.<route>, the inspect mount becomes /_voltro/inspect/plugins/<alias>/…
name "I want two of these" appends a #suffix discriminator so two installs never register the same tag
notificationsPlugin({ alias: 'alerts' })                    // alerts.inbox
notificationsPlugin({ name: 'ops' })                        // notifications#ops.inbox
notificationsPlugin({ alias: 'alerts', name: 'ops' })       // alerts#ops.inbox

alias exists to escape a tag collision, which is fatal at codegen. One cost is worth knowing before you reach for it:

  • the plugin-migration ledger key is <plugin-alias>__<migration.id>, so aliasing a plugin that ships extendSchema.migrations makes its already-applied migrations look unapplied. Choose the alias before first boot, not after.

The dashboard panel is NOT one of those costs. The dashboards fetch a plugin's inspect panel at its DEFAULT slug with the path compiled in — they live in other repositories and cannot follow an alias — so a plugin's inspect endpoints keep a mount under its canonical name alongside the aliased one. Aliasing does not take the panel away.

The exception is name, not alias: two installs of one plugin are two panels with one canonical name, so they get no shared mount. Showing either one under it would hand a dashboard the other install's rows under a name that looks right. Each install is reachable at its own slug, which /_voltro/inspect/plugins reports for every plugin as inspectSlug (alongside baseName, the canonical package name before any alias).

The plugin's own hooks are not one of those costs either — they follow the alias. useInbox(), useUpload(), useComments() and the rest resolve their wire tag at call time from the namespace your app installed the plugin under, so notificationsPlugin({ alias: 'alerts' }) makes useInbox() subscribe to alerts.inbox with no change at the call site. Two pieces make that work and both are automatic:

  • voltro dev writes a registerPluginAliases({ … }) declaration into rpcGroup.generated.ts — the module the web client already loads — mapping each installed plugin's canonical package name to the namespace it answers to;
  • each plugin's hooks call pluginTag(baseName, route) from @voltro/protocol instead of spelling the namespace.

If you install the SAME plugin twice with name (two instances) and no install is the un-suffixed primary, pluginTag refuses rather than guessing which one a hook means — call the route by its full tag (useSubscription(api, 'notifications#ops.inbox')) to say which install you want.

tables: false — keeping your own tables

Plugins whose tables carry no authorization or safety decision accept tables: false, which stops them contributing DDL through extendSchema so an app can keep equivalent tables it already has. Everything else — routes, inspect, interceptors — is unchanged.

notificationsPlugin({ tables: false })   // you declare the six notification tables

It is offered on @voltro/plugin-rbac, @voltro/plugin-audit, @voltro/plugin-notifications and @voltro/plugin-ai-flows. The plugin still writes to those tables BY NAME, so you take over declaring each one with the shape the package exports, and a missing or mis-shaped table fails at the first write rather than at boot.

It is deliberately NOT offered on plugins whose tables carry a guarantee. The SAML assertion replay cache, SCIM provisioning state, billing's usage counters, cdc-out's delivery outbox, the governance consent ledger and search's tenant-scoped index rows are examples, not the whole list — read "the plugin does not offer tables" as the answer, never "so every plugin not named here would let me". A tables: false there would disable a security or correctness decision with no signal to the app that it now owns it.

@voltro/plugin-storage is the case people expect to find in that list. _voltro_storage_grants decides who may read and write an object, so it belongs there — but the option would not reach it in any case: storage's four tables are contributed as framework tables, not through extendSchema, so there is nothing for a tables: false to switch off. Owning the grant table would need a grant-store seam, which does not exist yet.

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 that is reactive — every table is, unless it opted out with .nonReactive(). 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 the additional surfaces below, 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 scaffolding 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.

httpRoutes: PluginHttpRoute[] — raw HTTP endpoints on the framework listener

A plugin can mount plain HTTP routes beside the rpc surface (@voltro/plugin-storage's upload/download routes, an IdP callback). The request/response shape is transport-honest, and four properties are worth pinning:

  • The full method union is first-class. method is '*' | 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS'. HEAD is admitted wherever GET is (RFC 9110) — the GET handler runs and the transport drops the body; you never mount a second route for it. A wrong method stays a precise 405 with an Allow: header, including when several routes share one path.
  • The body read is capped — 8 MiB by default, the same cap as every other surface (http.maxBodyBytes in app.config.ts, env VOLTRO_MAX_BODY_BYTES), and the read is binary-clean. A route that takes more declares its own maxBodyBytes; routes sharing a path share one body read, so the widest override in the group applies to the group. Oversize answers 413 for both Content-Length and chunked requests.
  • Binary streaming responses return byteStream on the PluginHttpRouteResult — a ReadableStream<Uint8Array> (or lazy thunk) with optional contentLength / contentDisposition, piped without buffering and never compressed. It is the plugin-route spelling of the REST surface's bytes().
  • Buffered responses are compression-negotiated (brotli/gzip, compressible types only) by the listener — nothing to declare; see Security → compression.

A state-changing plugin route is origin-checked unless it declares originGuard: 'exempt', and req.remoteAddr is the trusted-proxy-resolved client address — both covered with examples in Security.

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) => {
      // `ctx.remoteAddr` is ALREADY resolved through the app's
      // `security.trustedProxies` policy — the same value the framework's own
      // rate limiter, geo-block and audit rows use. Never read
      // `ctx.headers['x-forwarded-for']`: it is a request header, so any
      // caller can write it, and a limiter keyed on it is bypassed by one
      // extra header. `undefined` only when the socket address is unavailable.
      const clientAddr = ctx.remoteAddr ?? 'unknown'
      if (buckets.consume(clientAddr, 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
    },
  })

ctx.remoteAddr is the client address — x-forwarded-for is not. The framework resolves remoteAddr through the app's security.trustedProxies policy (VOLTRO_TRUSTED_PROXIES) before it hands you the context: with no trusted proxy declared the header is ignored entirely and the socket address wins, and with one declared only the hops that are actually a configured proxy are believed. Reading ctx.headers['x-forwarded-for'] yourself throws that away and keys your limiter on a string the caller typed — one extra header and every request looks like a new client. The same rule applies to a plugin's raw HTTP routes, where the resolved value arrives as req.remoteAddr; see Trusted proxies.

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.

Runs on both boot paths. The chain is composed and installed identically by voltro dev and voltro serve — this is a production capability, and for a pre-auth shield production is the point. There is exactly ONE exemption, and it is deliberate: GET /internal/liveness and GET /internal/readiness are answered before the interceptor, so a rate-limit or geo-block plugin cannot 503 a Kubernetes probe and take the replica out of rotation.

The chain is FAIL-CLOSED. An interceptor that throws is a 500 plus a log line — the request does NOT continue. It used to: the failure was swallowed and the request flowed on, which meant a crashed security gate was an open one. That polarity puts a decision on every interceptor author: if your hook is a GATE (geo-block, bot detection), let a failure propagate — refusing is the correct degraded behaviour. If it is protection with a DEPENDENCY (a rate-limit counter in Redis), catch your own failure inside the hook and degrade loudly@voltro/plugin-ratelimit's httpShield does exactly that: a Redis outage means unlimited-with-a-warning, never a self-inflicted API outage. What no interceptor gets to do anymore is fail silently and stay in the chain.

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.

Which commands run them: voltro dev's boot auto-migrate, voltro db apply (bare and --plan) and voltro migrate --create-only. NOT voltro serve — serve never applies a schema, so a plugin's steps land in the pre-deploy job alongside the schema, which is where they belong. Until 0.34.0 only the voltro dev boot ran them, so a plugin's SQL steps executed on every developer machine and on no deployed database; if you ship migrations, verify against a deployed database rather than a dev boot.

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.claimChange(scope, key) — claim ONE change fleet-wide; true means this replica may run the effect. The counterpart of scheduleCoordinated for the OTHER thing that fires on every replica: a change tap. onChangeEvent is delivered to every replica — that is what makes a changeScope: 'fleet' store cross-instance — so a tap that COUNTS or SENDS multiplies by the replica count. @voltro/plugin-billing accrued a usage unit per metered row change and a tenant on two pods was invoiced twice.

    scope namespaces the key (use your plugin's name); key must name the CHANGE. Derive it with changeDigest + OccurrenceCounter from @voltro/database rather than inventing one — a fleet change carries no LSN, no commit id and no traceId, so content plus its position among content-identical repeats is the only thing two replicas provably agree on. A row id is not enough: two genuine updates to one row share it.

    AT MOST once — the claim is taken before the effect, so a replica that wins and dies takes the change with it, and a claim that cannot be written is taken by nobody. Fail-closed, like the rate slot and the budget guard.

  • 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.

    Each tick claims one row in _voltro_schedule_claims, so the interval you pick is also a write rate: 250 is four rows per second, per task, fleet-wide.

    The interval is a floor, not a cadence — if you report idleness. Return { idle: true } from the effect on a tick that found nothing to do, and the runner backs off toward a 30 s ceiling (VOLTRO_POLL_CEILING_MS) instead of ticking at your interval forever. Return nothing and the tick keeps its fixed interval, which is the safe default for a task that cannot tell.

    Or stop it entirely. Pass { disarmWhenIdle: true } as a fourth argument and an idle tick with nothing pending stops the timer altogether — the task runs once at startup and then only when wake() says so. That is the difference between a cheaper poller and no poller, and it is what the framework's own two background tasks do.

    Only pass it when an arrival is guaranteed to call wake(). That is a claim about the deployment, not the task: with Postgres LISTEN/NOTIFY or a broadcast broker every replica sees every write, so it holds. Without either, a peer replica's enqueue produces no local event and a disarmed task would sleep through it — the backoff ceiling is the correct choice there.

    Two more fields make that affordable rather than a latency tax:

    • handle.wake() runs a tick now. Wire it to whatever announces work — a store.onChange on the queue table your plugin drains — and the work starts on arrival rather than up to one interval later. Calls are coalesced to at most one extra tick per interval, so calling it per row is fine.
    • { idle: true, nextDueInMs } caps the backoff. Return it when the task is idle right now but already knows something is coming (a window closing, a lease expiring); the next tick lands on that instant instead of on the ceiling.
const outbox = ctx?.scheduleCoordinated('vendor.outbox', 1_000, async () => {
  const sent = await drainOutbox()
  return { idle: sent === 0 }
}, { disarmWhenIdle: true })

store.onChange((event) => {
  if (event.table === 'vendor_outbox' && event.op === 'insert') outbox?.wake()
})

Why this exists: two framework tasks polled permanently-empty queues on a deployment's deployment and wrote 2 506 claim rows an hour between them, against 18 from the app's own eight schedules. A fixed interval has no way to learn that a queue is empty. Without a wake() source the ceiling is your worst-case latency, so a task whose arrivals cannot announce themselves should keep reporting nothing.

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.