Skip to content

Plugin API contract

Plugin API version: 1.0 (introduced in BDPD v0.7)

This page is the formal contract between BDPD core and third-party plugin authors. The signatures, registries, and policies described here are stable: changes follow the semver policy at the bottom of this page, and every change is announced in docs/dev/changelog.md under a ## Plugin API section.

For v0.7, the Plugin API version is bound to the BDPD core version — there is no separate npm package or independent release cadence. A Plugin API bump may force a BDPD core bump (and vice versa). If BDPD ever ships a standalone plugin SDK package, the Plugin API gets its own semver track at that point; until then, plugin authors pin to a BDPD core version.

What counts as public API

A plugin author may rely on the following surface to stay backwards-compatible across patch and minor BDPD releases.

Plugin loader contract

Plugin files live in one of:

plugins/<category>/<name>.js              # user-installed
examples/plugins/<category>/<name>.js     # starter templates

Categories: engines, perturbations, schedulers, observation_transforms, metrics, victories.

Each file must export default a plugin spec object whose type field routes it to the right registry. The loader (tools/plugin-loader.mjs) imports each .js / .mjs file under the plugin roots, reads mod.default, validates required fields per type, and installs into the in-process registry.

A plugin whose name collides with a built-in or another loaded plugin overrides the existing entry and emits a warning to stderr. The override-with-warning policy is intentional (useful for experiments) — see Extend the Engine.

Files starting with _ and files named README.md are skipped (reserved for private helpers and documentation).

Plugin spec shapes

Each category requires type (matching the subdirectory), name (the registry key), and one or more type-specific fields. All other fields on the spec (description, author, version, license) are informational only — the loader reads them but does not act on them.

Engine (type: 'engine')

export default {
  type:    'engine',
  name:    'my_engine',
  factory: (modelSpec) => new MyEngine(modelSpec),   // returns BaseEngine instance
};

MyEngine must subclass BaseEngine from engine/base-engine.js or otherwise implement the engine signature below. The factory is called once per arena instantiation, receives the full modelSpec, and must return an object satisfying the engine contract. Use a regular function (not an arrow) if the factory will be new-ed by core code — buildEngine() currently calls new factory(modelSpec) for symmetry with built-in classes, and arrow functions cannot be used as constructors.

Perturbation (type: 'perturbation')

export default {
  type: 'perturbation',
  name: 'my_perturbation',
  apply: async (arena, payload, context) => {
    // mutate arena via setStock / setCapacity / setRegenRate / setThreshold
    return { changes: { stock: -2 }, description: 'shock applied' };
    // or signal "no-op":
    // return { applied: false, description: 'gate not crossed' };
  },
};

Scheduler (type: 'scheduler')

export default {
  type:    'scheduler',
  name:    'my_scheduler',
  factory: () => ({
    nextDemands(agents, state, rng) {
      return { [agentId]: numericDemand };   // demand per agent for this tick
    },
  }),
};

Observation transform (type: 'observation_transform')

export default {
  type:      'observation_transform',
  name:      'my_transform',
  transform: (value, params = {}, context = {}) => value,
};

params carries the per-variable spec written in the arena configuration (e.g. { std_fraction: 0.1 } for gaussian_noise). context carries arena-level state, including rng for any stochastic transform.

Metric (type: 'metric')

export default {
  type:      'metric',
  name:      'my_metric',
  compute:   (state)    => number,
  aggregate: (values[]) => number,
};

aggregate is currently consumed only by experimental code paths in the engine; it is required at the spec level for v1.0 forward-compatibility (nested arenas need aggregation defined per metric).

Victory (type: 'victory')

export default {
  type:      'victory',
  name:      'my_victory',
  victoryFn: (agents, commonsStock, gateArg) => ({
    winners:     [agentId, ...],
    score:       number,
    explanation: string,
  }),
};

Beyond the required fields, plugins are encouraged to declare:

export default {
  type: 'observation_transform',
  name: 'my_transform',
  transform: (...) => ...,

  // Recommended optional fields:
  version:          '0.2.1',          // your plugin's own version
  bdpd_plugin_api:  '^1.0',           // semver range this plugin targets
  requires:         { built_ins: ['gaussian_noise', 'resolution'] },
  description:      'Discretize observed values into N buckets',
  author:           'Your Name <you@example.com>',
  license:          'MIT',
};

The loader does not enforce these in v1.0 — they are documentation that plugin packaging tools (and a future plugin marketplace) can consume. Plugins missing these fields still load. A future minor bump may start warning when bdpd_plugin_api is absent.

Public registries

Registry Module Public symbol
Engines engine/engine-registry.js ENGINE_REGISTRY, buildEngine(modelSpec)
Perturbations platform/perturbation.js PERTURBATION_REGISTRY
Schedulers platform/scheduler.js SCHEDULER_REGISTRY, buildScheduler(name)
Observation transforms platform/observability.js OBSERVATION_TRANSFORM_REGISTRY
Metrics engine/metrics.js METRIC_REGISTRY
Victories engine/metrics.js VICTORY_REGISTRY

Importing these symbols directly (instead of going through the plugin loader) is supported — useful for libraries that want to inspect available plugins or register entries programmatically.

Stable engine signature

A plugin engine returned by factory must implement:

step(demands, rng, context = {})  { newStock, ... }
stepOne(...)  ...
applyRegen(...)  number
extraObservations(playerId)  { [key: string]: number }
checkEnd(state)  { ended: bool, reason: string }
toJSON()  engineState

Plus the setter contract added in v0.5.1 (required for perturbations to take effect):

setStock(value)
setCapacity(value)
setRegenRate(value)
setThreshold(value)

Subclassing BaseEngine (engine/base-engine.js) is the recommended path — it provides default implementations for the non-engine-specific methods.

Manifest schema 1.0

A plugin that emits sweeps and wants those sweeps to be consumed by tools/manifest_helper.py audit-paper / list-orphans / snapshot depends on the v0.6 manifest schema (with the v0.6.5 figure provenance extension). The schema is documented inside tools/manifest.mjs and is content-addressed via SHA-256 over the results JSON. The v0.6.5 figure provenance extension is backwards-compatible: pre-v0.6.5 manifests still validate.

What is not public API

The following are internals — they may change in any patch release without notice. Plugin authors must not import or depend on them.

  • platform/arena.js lifecycle (Arena, ArenaRegistry — internal state container; talk to it via the HTTP API instead)
  • api/server.js, api/routes/* — wire protocol may evolve; authoritative source is the API doc at /api/health
  • main.js boot sequence
  • Anything in tools/ other than manifest.mjs library exports and plugin-loader.mjs's loadPlugins() function
  • engine/commons.js step formula internals (the formula itself is stable per the paper; the internal helpers are not)
  • engine/seneca.js private state representation (capitalStock, pollutionLevel are observations but the underlying ODE integration is internal)
  • platform/sandbox.js, platform/reporter.js, platform/streamer.js — implementation details of the HTTP layer

Versioning policy

The Plugin API follows semver. The contract:

Major bump (X.0)

Required when any of the following happens:

  • A symbol listed under Public registries is renamed or removed.
  • The signature of a function under Plugin spec shapes changes incompatibly (a required argument is added, an existing argument's meaning changes, the return shape changes).
  • A built-in key (e.g. gaussian_noise, regen_shock) is removed from a registry.
  • A previously-deprecated symbol or behaviour is removed (must have been deprecated for at least one prior minor release, see Deprecation policy).
  • The manifest schema bumps to a new major version (tools/manifest.mjs schema 2.0+).

Minor bump (X.Y)

Allowed for:

  • Adding a new public symbol (new registry, new helper function).
  • Adding a new optional field to a spec shape (e.g. a new optional field cache_key on metric specs).
  • Adding a new built-in key to a registry (e.g. shipping a new built-in perturbation type).
  • Adding a new optional parameter to a stable function (with a backwards-compatible default).
  • Marking a symbol as deprecated (the symbol still works; removal waits for the next major).

Patch bump (X.Y.Z)

Allowed for:

  • Bug fixes that restore documented behaviour.
  • Documentation, comments, internal refactors that preserve every public signature and registry key.
  • Performance improvements with no observable behavioural change.

Concrete examples

Change Bump
Add OBSERVATION_TRANSFORM_REGISTRY.discretize built-in minor
Add optional seed field to perturbation apply payload minor
Rename OBSERVATION_TRANSFORM_REGISTRY to OBS_TRANSFORM_REGISTRY major
Remove built-in regen_shock perturbation major
Fix a bug where gaussian_noise ignored its seed under nested arenas patch
Add manifest_helper.py audit-paper --strict flag patch (CLI is internal)
Bump manifest schema to add a new optional field minor
Bump manifest schema with a breaking shape change major

Deprecation policy

A symbol or behaviour announced as deprecated in a minor release must survive at least until the next major bump before it can be removed. Concretely:

  • Add @deprecated since X.Y, removed in X+1.0 to the source (JSDoc comment or function-level comment).
  • Add an entry under ### Plugin API > Deprecations in changelog.md.
  • Optionally emit a runtime warning to stderr when the deprecated symbol is touched (recommended for high-traffic entry points).

Plugin authors reading the changelog get at least one minor release worth of lead time before any removal lands.

Native plugin trust model

Plugins under plugins/ and examples/plugins/ are loaded as native Node modules: they have full filesystem access, can spawn processes, and can import any package on disk. There is no sandboxing for native plugins in v1.0.

The current policy is trusted only, admin-loaded: the operator running BDPD chooses which .js files end up under the plugin roots. Do not point a BDPD instance at a plugin directory controlled by untrusted users.

For untrusted user-supplied logic (e.g. arena specs uploaded via HTTP), use the sandboxed code paths inside platform/sandbox.js — these are not part of the plugin contract and operate on restricted expressions, not full Node modules.

A signed-plugin mechanism (manifest with public-key signature, key allowlist on disk) is a v0.8+ open design question, gated on whether BDPD ever hosts a plugin marketplace.

Reporting compatibility

When a new BDPD release ships:

  • The plugin spec for every built-in lives in examples/plugins/<category>/ and is loaded at startup as part of the conformance test. If a release breaks any of these, the build fails before publication.
  • The strict regression suite (tools/check_regression.sh) exercises every registry under the canonical sweeps (P1, P8, P9, P10, P11). A passing run is a positive signal that no signature drifted silently.
  • Third-party plugin authors are encouraged to add their plugins to a CI matrix against tagged BDPD releases — there is no formal compatibility test runner shipped from core, but the conformance runner in tools/plugin-loader.mjs (invoked standalone) lists every spec the loader accepts under the current core, which serves as a smoke test.

Where to discuss changes

  • Breaking change proposal: open a GitLab issue with the plugin-api-break label. The merge window for major bumps aligns with BDPD major or minor releases.
  • New built-in (minor): propose via merge request; reviewer checks that the addition is backwards-compatible.
  • Bug fix (patch): no special process beyond the usual MR flow.

Edit history of this contract should be visible in docs/dev/changelog.md — every breaking change to a listed signature or registry is a release-note-worthy event and lands under the ### Plugin API sub-section of the relevant release.