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-auditrecords every mutation invocation to a sink.@voltro/plugin-multitenancyadds thetenant()schema mixin + write-guard.@voltro/plugin-webhooksships 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.ts — onChangeEvent 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
}onInstallruns ONCE per process perplugin@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_installstable) lands with the migration runner. Failure aborts boot.onUninstallis the teardown mirror ofonInstall— 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).onActivateruns 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.onDeactivateruns 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 bothvoltro devandvoltro 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.envexposesprocess.envbut 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 (
defineMixinfrom@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-notificationsis re-namespaced — underalias: 'inbox'it becomesinbox.inbox, notnotifications.inbox. A deeper path keeps its depth:'audit.admin.events'underalias: 'trail'becomestrail.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.inboxalias 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 shipsextendSchema.migrationsmakes 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 devwrites aregisterPluginAliases({ … })declaration intorpcGroup.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/protocolinstead 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 tablesIt 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.
methodis'*' | '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 precise405with anAllow: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.maxBodyBytesinapp.config.ts, envVOLTRO_MAX_BODY_BYTES), and the read is binary-clean. A route that takes more declares its ownmaxBodyBytes; routes sharing a path share one body read, so the widest override in the group applies to the group. Oversize answers413for bothContent-Lengthand chunked requests. - Binary streaming responses return
byteStreamon thePluginHttpRouteResult— aReadableStream<Uint8Array>(or lazy thunk) with optionalcontentLength/contentDisposition, piped without buffering and never compressed. It is the plugin-route spelling of the REST surface'sbytes(). - 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:mountpermission 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 liveSqlClient(from@effect/sql), the SAME pool the app's store uses. Run raw SQL through it instead of standing up your OWNManagedRuntime+ pool from env.undefinedon the in-memory store (no SQL engine) — guard withif (ctx.sql).ctx.claimChange(scope, key)— claim ONE change fleet-wide;truemeans this replica may run the effect. The counterpart ofscheduleCoordinatedfor the OTHER thing that fires on every replica: a change tap.onChangeEventis delivered to every replica — that is what makes achangeScope: 'fleet'store cross-instance — so a tap that COUNTS or SENDS multiplies by the replica count.@voltro/plugin-billingaccrued a usage unit per metered row change and a tenant on two pods was invoiced twice.scopenamespaces the key (use your plugin's name);keymust name the CHANGE. Derive it withchangeDigest+OccurrenceCounterfrom@voltro/databaserather than inventing one — a fleet change carries no LSN, no commit id and notraceId, 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-rolledsetIntervala plugin would otherwise run insidebindDataStore— 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 whosestop()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:250is 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 whenwake()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 — astore.onChangeon 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 | jqReturns one entry per plugin in app.config.ts's plugins: array with:
name,version,description,frameworksemver rangepermissions: string[]— declared scopeshooks— which interceptor + lifecycle hooks the plugin installedqueries—kind:nametuples for every plugin-contributed queryservices: boolean— whether the plugin contributes a service layerhasConfigSchema: boolean— whetherconfigSchemais declaredactivated,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.
Related
- Schema mixins — the OTHER plugin shape (schema-only, no runtime hooks).
- Auth strategies — a specialised plugin pattern for identity providers.