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,
}),
};
Optional manifest fields (recommended)¶
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):
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.jslifecycle (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/healthmain.jsboot sequence- Anything in
tools/other thanmanifest.mjslibrary exports andplugin-loader.mjs'sloadPlugins()function engine/commons.jsstep formula internals (the formula itself is stable per the paper; the internal helpers are not)engine/seneca.jsprivate state representation (capitalStock,pollutionLevelare 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.mjsschema 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_keyon 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.0to the source (JSDoc comment or function-level comment). - Add an entry under
### Plugin API > Deprecationsinchangelog.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-breaklabel. 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.