Skip to content

Plugins and the Rule Engine

FreeSDN’s automation engine is if-this-then-that: a trigger fires, conditions are evaluated, actions run. Plugins meet that engine, and the two directions are not symmetric: a plugin’s event can fire a rule, but a rule cannot invoke a plugin’s action. Both halves are worth stating plainly, including the second one.

Plugin → rule: your event fires an automation

Section titled “Plugin → rule: your event fires an automation”

When a plugin emits an event, the automation engine can trigger on it like any other:

# in your plugin
await self.ctx.events.emit("threshold_exceeded", {"device": name, "value": 91})

That publishes plugin.{your-id}.threshold_exceeded on the event bus. The engine subscribes to the whole bus, so a rule matches it with an ordinary event pattern:

{
"name": "Page on threshold",
"trigger_type": "event",
"trigger_config": { "event_pattern": "plugin.my-plugin.*" },
"actions": [
{ "action_type": "notify_slack", "params": { "channel": "#noc" } }
]
}

Declaring the trigger in on_start is optional for firing, but do it anyway - it is what makes the event appear in the Fabric catalogue and the automation UI, so somebody building a rule can discover it instead of guessing the string:

self.register_automation_trigger(
trigger_type="threshold_exceeded",
description="A monitored value crossed its configured threshold.",
schema={"type": "object", "properties": {"device": {"type": "string"}}},
)

The namespace is not decoration. A plugin can only emit under plugin.{its-own-id}., so it cannot publish an event that impersonates a platform one and trip rules written for device.status.changed.

Rule → plugin: not today, and here is why

Section titled “Rule → plugin: not today, and here is why”

You can register an action, and you should - it publishes the operation to the Fabric catalogue, where an operator or the AI assistant can find it:

async def quarantine_vlan(params: dict) -> dict:
...
return {"success": True, "moved": count}
self.register_automation_action(
action_type="quarantine_vlan",
handler=quarantine_vlan,
description="Move a client to the quarantine VLAN.",
params_schema={"type": "object", "properties": {"mac": {"type": "string"}}},
)

But an automation rule cannot invoke it. ActionType is a closed enum, so action_type: "plugin.my-plugin.quarantine_vlan" is rejected when the rule is parsed; and fabric.operation, the route native cross-module operations use, refuses any operation that is not NATIVE:

operation is not native

That gate is deliberate, and the reason is tenancy. A plugin has one runtime instance per organization, while the Fabric registry is process-wide and holds one operation per id. A handler captured when the operation is registered belongs to whichever organization happened to start the plugin first - so a rule in organization A invoking it would run organization B’s plugin runtime, against organization B’s settings and HTTP credentials.

This is not theoretical. The gate was removed once during a review, on the reading that it was unfinished wiring, and a rule in one organization immediately executed a handler bound to another’s. It was put back.

So: drive plugins with events, not with actions. The plugin → rule direction above is fully wired and has no such constraint. If your plugin needs to act when something happens, subscribe to the event in on_event and do the work in your own runtime, where the organization context is unambiguous:

async def on_event(self, event_type: str, payload: dict) -> None:
if event_type == "device.status.changed" and payload.get("status") == "offline":
await self.quarantine_vlan({"mac": payload["mac"]})

Per-organization dispatch would close the gap, and is not built. Until it is, an action registration is a catalogue entry: it makes what your plugin can do discoverable, and nothing more. The only thing that actually runs your code on the right organization’s runtime is a request to your plugin’s own REST route, or your own on_event handler.

Worth knowing if you read the source, because it is the one place this looked wired. automation_bridge used to copy your handler into the automation engine’s private handler dict under the key plugin.{id}.{action}.

It never worked. A rule names its action through ActionType, and Action.from_dict builds that with ActionType(value) - a closed enum, so ActionType("plugin.my-plugin.quarantine_vlan") raises and the key could never be the one looked up.

It did cause damage, though. The engine’s handler dict is keyed by ActionType, and the rule-create endpoint renders those keys with t.value when it refuses an unsupported action. A plain string has no .value, so installing any plugin that registered one action turned that helpful 422 into a 500 for every rule created in that process, plugin-related or not.

And it was a second door onto the same tenancy hazard: one process-wide handler, captured from whichever organization started the plugin first. It stayed shut only because the enum happened to reject the key. That write is gone, and the engine now refuses a handler key that is not a real ActionType.

Five callers can reach the Fabric executor. Every one of them refuses a plugin operation, for its own reason - which is the property that matters, because a gate holding on four paths and not the fifth is not a gate:

Caller What happens
POST /fabric/operations/{id}/invoke 501 - only native operations are invocable here
The AI assistant’s tool projection Skipped; a plugin operation never becomes a tool
An automation rule’s fabric.operation Fails with “operation is not native”
The Connection builder’s validator Authoring refused - a plugin step cannot be saved
The Negotiator running a saved Connection Permission denied, fail-closed

Behind all five, as defence in depth, the executor refuses a plugin write before any handler is reached:

plugin operation plugin.x.y may not be a write [PLUGIN_WRITE_FORBIDDEN]

Device writes reach hardware through exactly one path: native staging, with an operator applying the change. A rule that could drive a plugin write would be a second path around that, opened to anyone who can author a rule.

Two things hold on the invocations that do happen - a rule running a native operation, and your plugin doing its own work:

  • The rule author’s permission is re-checked at fire time. A rule written by an admin who was later demoted or moved out of the organization stops working, rather than continuing under the stored actor.
  • Your plugin’s work is supervised. Its lifecycle hooks, event handlers, and routes count against an error budget, and a quarantined plugin is refused rather than retried on every firing. See Running Plugins in Production.

This applies to every action a rule runs, not only the ones near plugins, and it is worth stating because it did not always hold.

An action handler reports failure either by raising or by returning {"success": False, "error": "..."}. The second form is the common one - it is how the camera actions and every fabric.operation failure path report - and the engine used to ignore it, recording the action as successful whenever the handler returned without raising. A camera that refused a PTZ move showed as a green rule execution.

A green execution that did nothing is worse than an error, because it removes the signal that anything needs attention. Both forms are now honoured, and the rule history says which failed and why.

async def my_action(params: dict) -> dict:
if not params.get("mac"):
return {"success": False, "error": "mac is required"}
...
return {"success": True}

The two halves compose into a loop that crosses every tier:

Omada reports a device offline → FreeSDN publishes device.status.changed → your plugin’s on_event enriches it and emits plugin.my-plugin.threshold_exceeded → an automation rule triggers on that → the rule invokes fabric.operation against a native operation that stages a firewall change → an operator applies it.

Every hop is observable, every plugin hop is supervised, and the only thing that touches hardware is the staged change an operator approved.

All product names, logos, and brands are property of their respective owners. FreeSDN is an independent project and is not affiliated with or endorsed by the vendors it integrates with. See Trademarks.