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 pluginawait 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 nativeThat 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.
The door that looked open
Section titled “The door that looked open”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.
Where the boundary sits
Section titled “Where the boundary sits”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.
How a rule records failure
Section titled “How a rule records failure”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}Together
Section titled “Together”The two halves compose into a loop that crosses every tier:
Omada reports a device offline → FreeSDN publishes
device.status.changed→ your plugin’son_eventenriches it and emitsplugin.my-plugin.threshold_exceeded→ an automation rule triggers on that → the rule invokesfabric.operationagainst 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.