Plugins

Overview

Plugins are lightweight, versioned QuickJS programs that perform data transformation without model calls. They complement agents --- where agents call models to reason, plugins deterministically reshape, check, and score data in the processing pipeline.

The philosophy: write plugins in the Workbench, execute them in the platform. The Workbench (the authoring UI) handles writing, bundling, and testing. The platform only runs pre-validated code.

PropertyPluginAgent
Calls a model?NoYes
ExecutionQuickJS sandboxLLM inference
PurposeTransform / check / score dataReason and synthesize
DeterminismDeterministicProbabilistic

Plugin Types

There are three plugin types, each with a fixed input/output contract:

TypePurposeContract
ExtractorTransform unstructured → structuredextract(input) → { data, confidence }
ValidatorCheck data against rulesvalidate(input) → { valid, errors }
EvaluatorScore content qualityevaluate(input) → { score, passed, feedback }

Extractor Contract

An extractor takes structured data plus configuration and returns extracted data with a confidence score (0.0--1.0).

// Input: any structured data + configuration
// Output: extracted data with confidence score
function extract(input) {
  const { data, config } = input;

  // Process data...

  return {
    data: { /* extracted fields */ },
    confidence: 0.95  // 0.0-1.0, how confident in extraction
  };
}

Validator Contract

A validator checks data against rules and returns a pass/fail flag with a list of error strings.

// Input: data to validate + rules
// Output: pass/fail with error list
function validate(input) {
  const { data, rules } = input;
  const errors = [];

  // Check rules...
  if (!data.required_field) {
    errors.push("Missing required field");
  }

  return {
    valid: errors.length === 0,
    errors: errors  // String array
  };
}

Evaluator Contract

An evaluator scores content against criteria and returns a score, a pass/fail decision against a threshold, and human-readable feedback.

// Input: content to evaluate + criteria
// Output: score, pass/fail, feedback
function evaluate(input) {
  const { content, criteria, threshold = 0.7 } = input;

  // Score content...
  const score = calculateScore(content, criteria);

  return {
    score: score,           // 0.0-1.0
    passed: score >= threshold,
    feedback: "Content meets 85% of criteria"
  };
}

Unified Schema

All three plugin types share a single plugins table with a plugin_type discriminator column, rather than separate tables per type.

CREATE TYPE plugin_type AS ENUM ('extractor', 'validator', 'evaluator');

CREATE TABLE plugins (
    id UUID PRIMARY KEY,
    tenant_id UUID NOT NULL REFERENCES tenants(id),

    -- Identity (versioned by name within type)
    plugin_type plugin_type NOT NULL,
    name TEXT NOT NULL,
    version INTEGER NOT NULL DEFAULT 1,
    description TEXT,

    -- Code storage
    code TEXT NOT NULL,           -- Source JavaScript
    bundled_code TEXT,            -- esbuild output (optional)

    -- NPM dependencies (for Workbench bundling)
    dependencies JSONB DEFAULT '{}',

    -- Schema definitions
    input_schema JSONB,           -- JSON Schema for input
    output_schema JSONB,          -- JSON Schema for output

    -- Metadata
    is_active BOOLEAN DEFAULT true,
    is_global BOOLEAN DEFAULT false,

    UNIQUE(tenant_id, plugin_type, name, version)
);

Why a Single Table

A single table with a discriminator wins over separate extractors / validators / evaluators tables because the three types are structurally identical:

  • Same storage needs --- code, bundled_code, dependencies, input/output schemas
  • Same execution model --- all run in QuickJS
  • Same API patterns --- CRUD plus execute
  • Extensible --- adding a new plugin type later is trivial; no new table, no new migration shape

The is_global flag and the (tenant_id, plugin_type, name, version) uniqueness constraint do the rest of the work that separate tables would otherwise require.

Execution Model

Plugins run in isolated QuickJS contexts. Each execution gets a fresh, fully isolated context --- no shared state, no escape hatches.

Sandbox Limits

LimitValue
Memory4MB (lighter than renderers)
Stack256KB
Filesystem accessNone
Network accessNone
Module importsNone (use bundled_code for dependencies)

Because there is no module system in the sandbox, NPM dependencies must be pre-bundled into self-contained JavaScript (bundled_code) by the Workbench before publishing.

Execution Functions

#![allow(unused)]
fn main() {
// Execution functions (source code)
execute_extractor(code, input, tenant_id) → ExtractResult
execute_validator(code, input, tenant_id) → ValidateResult
execute_evaluator(code, input, tenant_id) → EvaluateResult

// Bundled variants (esbuild output)
execute_bundled_extractor(bundled_code, input, tenant_id)
execute_bundled_validator(bundled_code, input, tenant_id)
execute_bundled_evaluator(bundled_code, input, tenant_id)
}

Built-in Plugins

A set of global plugins is seeded in migration and available to all tenants out of the box:

TypePluginPurpose
Extractorjson-pathExtract fields using dot-notation paths
Extractorregex-extractExtract data using regex patterns
Validatorrequired-fieldsCheck required fields are present
Validatortype-checkValidate field types
Validatorrange-checkValidate numeric ranges
EvaluatorcompletenessScore based on filled vs. expected fields
Evaluatorlength-checkEvaluate content length
Evaluatorkeyword-coverageCheck presence of expected keywords

Global vs. Tenant Plugins

Plugins are either global (available to every tenant) or tenant-specific.

AttributeGlobalTenant-specific
Created byAdmins onlyAny authorized user
Visible toAll tenantsSingle tenant
Stored withGlobal tenant UUIDUser's tenant UUID
Modify / DeleteAdmins onlyTenant users

Precedence: when a tenant-specific plugin and a global plugin share the same name, the tenant-specific plugin wins. This lets a tenant override a built-in with its own implementation without affecting other tenants.

Versioning

Plugins are versioned by the tuple (tenant_id, plugin_type, name, version):

  • Creating a plugin with an existing name bumps the version rather than overwriting
  • get_plugin_by_name returns the latest active version
  • Old versions remain in place for audit and rollback

Error Handling

Plugin code returns structured results, not exceptions. A plugin should never rely on throwing to signal failure --- it should return a result that conveys the failure.

// BAD: throws and crashes
function extract(input) {
  throw new Error("Failed");
}

// GOOD: returns error in result (handled by wrapper)
function extract(input) {
  if (!input.data) {
    return { data: null, confidence: 0 };
  }
  // ...
}

The Rust execution wrapper still catches any JavaScript exceptions that do escape and converts them into typed Rust errors --- but the contract is to fail gracefully through the return value.

REST Endpoints

MethodPathDescription
GET/v1/pluginsList all plugins
GET/v1/plugins/type/:typeList plugins by type
GET/v1/plugins/:type/:nameGet a specific plugin
POST/v1/pluginsCreate a plugin
POST/v1/plugins/:type/:name/executeExecute a plugin
PATCH/v1/plugins/:type/:name/bundleUpdate bundled code
DELETE/v1/plugins/:type/:nameDeactivate (soft delete)

Workbench Integration

The Workbench (authoring UI) owns the full plugin lifecycle up to publishing:

  1. Plugin authoring with syntax highlighting
  2. NPM dependency resolution
  3. esbuild bundling into self-contained JavaScript
  4. Testing against sample inputs
  5. Publishing to the platform via POST /v1/plugins

The platform executes only pre-validated, pre-bundled code --- keeping the runtime simple, sandboxed, and fast.

Implementation

FilePurpose
migrations/009_plugins.sqlDatabase schema + seeded built-in plugins
crates/qadra-traits/src/lib.rsPlugin types, PluginRepository trait
crates/qadra-db-postgres/src/lib.rsPluginRepository implementation
src/runtime/plugin.rsQuickJS execution functions
src/api/routes/v1/plugins.rsREST API handlers