Connections
A Connection is the core authoring unit of the Fabric subsystem. You pick a source event from the catalog, add optional conditions to filter which events matter, then build a step chain of operations that fires whenever a matching event arrives on the live bus. Read operations run immediately; write operations stage a pending change and wait for an explicit operator sign-off before touching any device.
This page covers every phase of authoring a Connection: the schema fields, event sources, condition syntax, templating rules, the Negotiator’s at-most-once semantics, the write-staging contract, and worked examples.
What a Connection looks like
Section titled “What a Connection looks like”{ "name": "Omada change applied → snapshot Proxmox VM", "source_event": "controller.change.applied", "description": "Before any staged Omada write goes live, snapshot the gateway VM.", "conditions": { "logic": "and", "conditions": [ { "field": "vendor", "operator": "eq", "value": "omada" } ] }, "cooldown_seconds": 60, "enabled": true, "steps": [ { "operation_id": "hypervisor.vm.snapshot", "params": { "controller_id": "{{trigger.controller_id}}", "node": "pve", "vmid": 100, "snapname": "pre-change-{{trigger.change_id}}", "description": "Pre-change snapshot - feature: {{trigger.feature}}" }, "continue_on_error": false } ]}Top-level fields
Section titled “Top-level fields”| Field | Required | Type | Constraints | Purpose |
|---|---|---|---|---|
name | Yes | string | 1-255 chars | Display label in the UI |
source_event | Yes | string | 1-255 chars | Event type from the Fabric catalog |
description | No | string | - | Free text; shown in the UI |
conditions | No | object | max 32 conditions, max depth 10 | Filter events before running the chain |
cooldown_seconds | No | integer | 0-86 400 | Minimum seconds between two firings of this Connection |
enabled | No | boolean | default true | Disabled Connections are inert on the bus |
steps | Yes | array | 1-25 steps | Ordered list of operations to execute |
Source Events
Section titled “Source Events”source_event must match an event_type exposed in the Fabric catalog (GET /api/v1/fabric/catalog). You can also use pattern wildcards:
| Pattern form | Matches |
|---|---|
exact string, e.g. device.status.changed | Only that event type |
a.b.* | One additional segment: a.b.X but not a.b.X.Y |
a.b.# | Any depth: a.b, a.b.X, a.b.X.Y |
* | Every event on the bus |
Use * or # patterns with a cooldown_seconds value; a Connection wired to every event with no cooldown can saturate the bus under high-velocity device activity.
Platform-wide event sources
Section titled “Platform-wide event sources”These are always available regardless of which modules or adapters are active:
| Event type | When it fires | Key payload fields |
|---|---|---|
controller.change.applied | A staged device write was successfully applied | change_id, controller_id, feature, operation, vendor, status, site_id, actor_id |
controller.change.staged | A write landed in the pending-changes queue awaiting sign-off | same shape |
controller.change.failed | A staged write failed at the device | same shape |
device.status.changed | A managed device went online or offline | device_id, site_id, data.name, data.old_status, data.new_status, data.reason |
device.discovered | A new device was discovered or adopted on a controller | device_id, site_id, data.* |
device.updated | A managed device record changed (rename, re-IP, firmware) | device_id, site_id, data.* |
ingest.external | An external system called POST /fabric/ingest | name, data (caller-supplied) |
Module-declared event sources (selected)
Section titled “Module-declared event sources (selected)”| Event type | Provider | Carries artifact |
|---|---|---|
cameras.event.motion | Video Surveillance | image/jpeg snapshot handle |
cameras.event.person | Video Surveillance | image/jpeg snapshot handle |
camera.status.online / camera.status.offline | Video Surveillance | - |
camera.snapshot.captured | Video Surveillance | image/jpeg snapshot handle |
network.vlan.created / .updated / .deleted | Network Management | - |
network.wifi.created / .updated / .deleted | Network Management | - |
gateway.sync.completed | Firewall / Orchestration | - |
gateway.brain.offline | Firewall / Orchestration | - |
storage.pool.degraded / storage.capacity.warning | Storage | - |
backup.validation.failed | Configuration Backup | - |
ai.budget.warning | AI Assistant | - |
Events that carry an artifact (e.g. cameras.event.motion) let you wire a downstream step that accepts image/jpeg - the Negotiator threads the artifact reference through without inlining binary data into the event bus. The suggest endpoint (GET /api/v1/fabric/connections/suggest?source_event=cameras.event.motion) annotates each compatible operation with "match": "artifact" so the UI can show the paperclip hint.
Conditions
Section titled “Conditions”Conditions gate which events actually proceed to the step chain. Omit conditions entirely to fire on every matching event.
Structure
Section titled “Structure”{ "logic": "and", "conditions": [ { "field": "vendor", "operator": "eq", "value": "opnsense" }, { "field": "feature", "operator": "contains", "value": "firewall" } ]}You can nest condition groups:
{ "logic": "or", "conditions": [ { "logic": "and", "conditions": [ { "field": "vendor", "operator": "eq", "value": "omada" }, { "field": "data.new_status", "operator": "eq", "value": "offline" } ] }, { "field": "data.reason", "operator": "contains", "value": "power" } ]}Maximum nesting depth is 10; maximum conditions per group is 32.
Operators
Section titled “Operators”| Operator | Meaning |
|---|---|
eq / ne | Equal / not equal |
gt / gte / lt / lte | Numeric comparison |
contains / not_contains | String substring check |
matches | Regex match (ReDoS-guarded; input capped at 4 KB) |
in / not_in | Value in a list (list ≤ 64 items, each string ≤ 1 024 chars) |
exists / not_exists | Field presence check |
field is a dot-path into the event payload (data.new_status, vendor, site_id). Use the field picker in the Connection builder to browse available paths for a chosen event type.
Each step names an operation_id from the Fabric catalog and a params object. Steps run in order. If a step with "continue_on_error": false fails, the run stops and subsequent steps are skipped. There is no partial rollback.
Step fields
Section titled “Step fields”| Field | Required | Default | Description |
|---|---|---|---|
operation_id | Yes | - | Dotted operation id from the catalog |
params | Yes | - | Object; values may use {{...}} template expressions |
continue_on_error | No | true | If false, a failure in this step aborts the chain |
Built-in sink operations
Section titled “Built-in sink operations”These three operations are always available and have no side-effect on devices:
| Operation id | What it does | Required params |
|---|---|---|
fabric.notify | Dispatches a multi-channel notification (email, Slack, in-app, webhook) | channels (required - JSONB channel config; an empty object {} dispatches nothing); title (default: "FreeSDN Fabric") and body (default: "") are optional |
fabric.log | Writes a structured Fabric log entry (audit sink; useful for wiring validation) | message |
fabric.webhook | HTTP POST to an external URL (n8n, Zapier, Make, Node-RED) | url (required), method (default POST), payload, headers |
fabric.notify truncates title to 200 characters and body to 4 000 characters. fabric.webhook enforces a 30-second timeout, caps the response body at 16 KB, and routes every request through the SSRF guard (safe_http_request) - it can never reach loopback, cloud-metadata addresses, or private ranges unless the deploy owner has explicitly added the host to FABRIC_WEBHOOK_ALLOWED_HOSTS. See Fabric overview for webhook security details.
Module-level operations (selected)
Section titled “Module-level operations (selected)”| Operation id | Provider | Write? | Permission required |
|---|---|---|---|
cameras.snapshot | Video Surveillance | No | (read) |
storage.health | Storage | No | storage.view |
storage.store_blob | Storage | Yes | storage.write |
hypervisor.vm.snapshot | Compute | Yes | hypervisor.manage_snapshots |
hypervisor.vm.start / .stop / .shutdown / .reboot | Compute | Yes | hypervisor.manage_vms |
network.client.list | Network Management | No | network.view |
firewall.search_alerts | Firewall | No | firewall.view_logs |
voip.phone.live_status | VoIP & Telephony | No | voip.view |
Safe Templating
Section titled “Safe Templating”Step params values may embed {{...}} expressions resolved at runtime. The template engine is a dotted-path lookup only - there is no expression evaluation, no arithmetic, no function calls, and no attribute access of any kind. Server-side template injection is impossible by construction.
Context shape
Section titled “Context shape”{ "trigger": <the source event payload>, "steps": [ { "output": <step 0 output>, "artifact": <ArtifactRef | null>, "success": true/false }, { "output": <step 1 output>, "artifact": <ArtifactRef | null>, "success": true/false }, ... ]}Reference forms
Section titled “Reference forms”| Expression | Resolves to |
|---|---|
{{trigger}} | The full source event payload (object, preserved as native type) |
{{trigger.vendor}} | A top-level field from the event payload |
{{trigger.data.new_status}} | A nested field (dot-separated path) |
{{steps.0.output.change_id}} | Output from step 0 (zero-indexed) |
{{steps.1.artifact}} | The ArtifactRef produced by step 1 |
A reference that is the only content of a string value preserves the native type ({{trigger.count}} → integer, not "5"). A reference embedded in a longer string renders to a string: "Device {{trigger.device_id}} is offline".
A missing path resolves to null (not an error). The chain does not abort on a missing reference - check required fields with a condition if downstream steps depend on them.
Limits
Section titled “Limits”| Limit | Value |
|---|---|
| Maximum path depth | 12 segments |
| Maximum rendered string length | 16 KiB |
| Maximum referenced object size | 256 KiB (truncated if over) |
The Negotiator
Section titled “The Negotiator”The Negotiator is the Connection runtime. It subscribes to the live event bus under a * (all-events) fan-out, applies source-pattern matching and org-scoping to determine which Connections a given event triggers, then executes the step chain for each match.
At-most-once per (Connection, event)
Section titled “At-most-once per (Connection, event)”Under a multi-worker deployment, every gunicorn worker’s Negotiator instance sees every event. To prevent duplicate firings, the Negotiator claims each (connection_id, event_id) pair with a Redis SET NX EX 300 before running:
fabric:fired:{connection_id}:{event_id} NX EX 300Only one worker wins the claim. The others skip silently. This is the primary cluster-wide deduplication guard.
Cooldown
Section titled “Cooldown”A cluster-wide cooldown key suppresses repeated firings of the same Connection within cooldown_seconds:
fabric:cooldown:{connection_id} NX EX {cooldown_seconds}If Redis is down, the Negotiator falls back to an in-process timestamp. Cooldown is particularly important for Connections that could create feedback loops (e.g. ingest.external → fabric.webhook whose target posts back to /fabric/ingest).
Author permission re-check
Section titled “Author permission re-check”Every step’s Operation has a declared permission. Before executing, the Negotiator re-resolves the author’s current permissions from the DB. If the author has since been deactivated, moved to a different org, or had permissions reduced, the step fails with PERMISSION_DENIED rather than proceeding under stale cached grants.
Org isolation
Section titled “Org isolation”A Connection only fires on events from its own organization. The org check is fail-closed: a missing org field on an event causes the Connection to skip, not fire.
Concurrency limit
Section titled “Concurrency limit”A semaphore caps concurrent chain executions at 16 per API process. An event burst that arrives faster than chains can complete queues at the semaphore rather than spawning unbounded asyncio tasks or exhausting the DB connection pool.
Run recorder
Section titled “Run recorder”Every firing writes a ConnectionRun row: source event type, trigger payload, per-step results, success flag, and duration in milliseconds. View runs at GET /api/v1/fabric/connections/{id}/runs or in the UI under Fabric → Connections → {name} → Runs.
Writes Ride Staging + Per-Action Sign-Off
Section titled “Writes Ride Staging + Per-Action Sign-Off”Any step whose operation_id resolves to a write operation (write: true in the catalog) never touches a device directly. The Negotiator routes it through AdapterStagingService.stage_change, which creates a pending change row and returns the change_id. The step output is { "staged": true, "change_id": "...", "feature": "...", "operation": "..." }.
The device is not contacted until an operator navigates to Pending Changes and explicitly approves the change through the dual-gate (ADAPTER_READ_ONLY=false at the system level AND the individual per-change sign-off).
This is the same pipeline that governs all adapter writes in FreeSDN - there is no special bypass path for Fabric-initiated writes.
What happens if a write step is in the middle of a chain?
Section titled “What happens if a write step is in the middle of a chain?”The chain continues with the staged step’s change_id available as {{steps.N.output.change_id}}. A downstream step can reference it (for example, log it). The device-side action is deferred; the chain itself completes.
Durable artifact handoff for writes
Section titled “Durable artifact handoff for writes”If a write step also needs a binary artifact (for example, storage.store_blob accepting a camera snapshot), the artifact is copied from the transient broker to the durable store at staging time. The staged change payload carries only a reference (token + sha256 + size). If the durable store write fails after staging, the staged change row is rolled back to avoid an orphan.
Plugin writes are refused
Section titled “Plugin writes are refused”Plugin operations (tier: plugin) are categorically refused by the executor with PLUGIN_WRITE_FORBIDDEN. Device writes may only flow from native, operator-authored steps. Plugin ops reach the platform through their own bounded runtime, not through the Fabric write pipeline.
Authoring: who can create Connections?
Section titled “Authoring: who can create Connections?”Creating, updating, and deleting Connections requires an unscoped org-admin role: the API key or session must be scoped to at least org_admin and must not be narrowed by a custom scope set that excludes org-admin authority. Viewers, operators, and site-admins can list Connections and view runs but cannot author them.
At create time the service validates that the authoring user holds every permission required by every step’s operation. A step referencing hypervisor.manage_vms from a user without that permission is rejected with ConnectionPermissionError before the Connection is stored.
Non-authors (including operators and viewers) see step params redacted:
{ "operation_id": "fabric.webhook", "params": { "__redacted__": "hidden - author-only" } }The operation_id is preserved for visibility; the params blob (which may contain a webhook URL or Slack hook) is dropped entirely. This is a whole-blob redaction, not a key-name heuristic.
API Endpoints
Section titled “API Endpoints”| Method | Path | Purpose | Permission |
|---|---|---|---|
POST | /api/v1/fabric/connections | Create a Connection | Unscoped org-admin |
GET | /api/v1/fabric/connections | List Connections (enabled? filter) | Any org member |
GET | /api/v1/fabric/connections/suggest | Compatible operations for a source_event | Any org member |
GET | /api/v1/fabric/connections/{id} | Get one Connection | Any org member |
PATCH | /api/v1/fabric/connections/{id} | Update a Connection | Unscoped org-admin |
DELETE | /api/v1/fabric/connections/{id} | Delete a Connection | Unscoped org-admin |
GET | /api/v1/fabric/connections/{id}/runs | Run history (max 200) | Any org member |
POST | /api/v1/fabric/connections/{id}/test | Fire once with an explicit payload | Unscoped org-admin |
The suggest endpoint is the matchmaking surface for the step builder. Pass ?source_event=cameras.event.motion and it returns compatible operations annotated with "match": "artifact" (the op accepts the event’s media type) or "match": "data" (params-only, no artifact), plus "allowed": true/false based on whether the calling user holds the required permission. Results are sorted: authorable artifact-match → authorable data-match → alphabetical.
Worked Examples
Section titled “Worked Examples”1. Device goes offline → in-app notification + email
Section titled “1. Device goes offline → in-app notification + email”{ "name": "Device offline → notify ops team", "source_event": "device.status.changed", "conditions": { "logic": "and", "conditions": [ { "field": "data.new_status", "operator": "eq", "value": "offline" } ] }, "cooldown_seconds": 120, "steps": [ { "operation_id": "fabric.notify", "params": { "channels": { "in_app": { "enabled": true }, "email": { "enabled": true } }, "title": "Device offline: {{trigger.data.name}}", "body": "Device {{trigger.device_id}} at site {{trigger.site_id}} went offline. Previous status: {{trigger.data.old_status}}." } } ]}The cooldown_seconds: 120 prevents a flapping device from sending one email every second.
2. Camera motion event → capture snapshot → store blob (staged)
Section titled “2. Camera motion event → capture snapshot → store blob (staged)”{ "name": "Motion detected → store snapshot", "source_event": "cameras.event.motion", "cooldown_seconds": 30, "steps": [ { "operation_id": "cameras.snapshot", "params": { "camera_id": "{{trigger.camera_id}}" }, "continue_on_error": false }, { "operation_id": "storage.store_blob", "params": { "controller_id": "{{trigger.controller_id}}", "label": "motion-{{trigger.device_id}}" } } ]}Step 0 (cameras.snapshot) runs immediately and produces an image/jpeg artifact. Step 1 (storage.store_blob) accepts image/jpeg and receives that artifact automatically via the Negotiator’s artifact threading. Because storage.store_blob is a write operation, step 1 stages a pending change - an operator must sign off before the blob is committed to storage.
3. External webhook ingest → route by name → notify
Section titled “3. External webhook ingest → route by name → notify”{ "name": "n8n alert → in-app", "source_event": "ingest.external", "conditions": { "logic": "and", "conditions": [ { "field": "name", "operator": "eq", "value": "n8n-critical" } ] }, "cooldown_seconds": 10, "steps": [ { "operation_id": "fabric.notify", "params": { "channels": { "in_app": { "enabled": true } }, "title": "n8n alert", "body": "{{trigger.data.message}}" } } ]}The external system calls POST /api/v1/fabric/ingest with { "name": "n8n-critical", "payload": { "message": "..." } } using an API key scoped to event:write. The ingest.external event fires; the condition on name routes only this Connection. Rate limit: 120 ingests per 60-second window, cluster-wide.
4. Staged Omada write applied → snapshot Proxmox VM
Section titled “4. Staged Omada write applied → snapshot Proxmox VM”{ "name": "Omada change applied → Proxmox snapshot", "source_event": "controller.change.applied", "conditions": { "logic": "and", "conditions": [ { "field": "vendor", "operator": "eq", "value": "omada" }, { "field": "status", "operator": "eq", "value": "applied" } ] }, "cooldown_seconds": 300, "steps": [ { "operation_id": "hypervisor.vm.snapshot", "params": { "controller_id": "{{trigger.controller_id}}", "node": "pve", "vmid": 100, "snapname": "post-change", "description": "Post-change snapshot - {{trigger.feature}} / change {{trigger.change_id}}" }, "continue_on_error": false }, { "operation_id": "fabric.log", "params": { "message": "VM snapshot staged for change {{trigger.change_id}}. Pending change id: {{steps.0.output.change_id}}" } } ]}Step 0 stages the VM snapshot (a write); step 1 logs the change_id returned in step 0’s output. Both run within the same Connection firing. The VM snapshot itself waits for a separate operator sign-off in Pending Changes.
5. IDS/IPS critical alert → webhook to SIEM
Section titled “5. IDS/IPS critical alert → webhook to SIEM”curl -sX POST https://<freesdn>/api/v1/fabric/connections \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "IDS critical → SIEM webhook", "source_event": "firewall.event.ids_critical", "cooldown_seconds": 5, "steps": [ { "operation_id": "fabric.webhook", "params": { "url": "https://siem.example.com/api/events", "method": "POST", "headers": { "Authorization": "Bearer siem-token-here" }, "payload": { "source": "freesdn", "rule": "{{trigger.rule_name}}", "src_ip": "{{trigger.src_ip}}", "dst_ip": "{{trigger.dst_ip}}" } } } ] }'The SIEM URL must be a public destination or listed in FABRIC_WEBHOOK_ALLOWED_HOSTS. If FABRIC_WEBHOOK_SIGNING_SECRET is set, the POST carries X-Fabric-Signature and X-Fabric-Timestamp for replay-protected verification on the SIEM side.
Testing a Connection
Section titled “Testing a Connection”Before wiring a Connection to live events, use the test endpoint to fire it with a synthetic payload:
curl -sX POST https://<freesdn>/api/v1/fabric/connections/{id}/test \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "payload": { "device_id": "aabbcc112233", "site_id": "00000000-0000-0000-0000-000000000001", "data": { "name": "test-switch", "new_status": "offline", "old_status": "online" } } }'The test run applies the same per-step permission gate and write-staging path as a live firing. Write steps stage a real pending change; they do not auto-apply. The run is recorded in the run history identically to a live firing - there is no distinguishing marker stored on the run record.
Configuration Reference
Section titled “Configuration Reference”| Environment variable | Default | Effect |
|---|---|---|
FABRIC_WEBHOOK_ALLOWED_HOSTS | "" | Comma-separated hostnames or IPs that fabric.webhook may reach even if private/LAN/tailnet. Deploy-owner controlled; still DNS-pinned and TLS-verified. |
FABRIC_WEBHOOK_SIGNING_SECRET | "" | When set, every fabric.webhook POST carries X-Fabric-Signature: sha256=<hmac> over the body plus X-Fabric-Timestamp. Rotate via env. |
FABRIC_ARTIFACT_DURABLE_DIR | /data/fabric_artifacts | Durable blob storage for staged write steps. Must be a persistent volume in production. |
REDIS_URL | - | Required for cluster-wide at-most-once, cooldown, and ingest-throttle guards. Without Redis the guards fall back to per-process behavior. |
Limits and Constraints
Section titled “Limits and Constraints”| Item | Limit |
|---|---|
| Steps per Connection | 25 |
| Conditions per group | 32 |
| Condition group nesting depth | 10 |
| Cooldown range | 0-86 400 seconds |
ingest.external rate | 120 per 60-second window per org |
| Ingest request body cap | 64 KiB |
| Webhook response cap | 16 KiB |
| Webhook timeout | 30 seconds |
| Plugin artifact cap | 16 MiB |
| Transient artifact cap | 64 MiB, TTL 1 hour |
| Concurrent chains per API worker | 16 |
Next Steps
Section titled “Next Steps”- Fabric Catalog - browse the full operation and event catalog, understand the tier model, and see executor security details.
- n8n Integration - wire Connections to external automation using the n8n community node or the
fabric.webhook/POST /fabric/ingestbridge. - Plugin SDK - register custom trigger event sources and callable operations from a plugin, then reference them in Connections.
- Marketplace - install signed community plugins that add new operations and event sources to the catalog.