Skip to content

The Fabric

The Fabric is FreeSDN’s universal app-interconnect. Every module that declares capabilities - cameras, hypervisor, storage, network, firewall, VoIP - publishes those capabilities into a single tier-tagged catalog of Operations (things you can do) and EventSpecs (things a module emits). You author Connections that wire an event source to a step chain. The in-process Negotiator runs those chains on the live event bus. No code required.

This page covers:

  • the catalog vocabulary (tiers, operations, events)
  • the built-in sinks (fabric.notify / fabric.log / fabric.webhook)
  • the executor’s two-track read-vs-write model and why writes are always staged
  • how inbound (/fabric/ingest) and outbound (fabric.webhook) bridge external systems
  • the key env vars and production deployment constraints

To learn how to author a Connection step-by-step, see Connections.


GET /api/v1/fabric/catalog returns a snapshot of everything the platform can do or can emit, plus a count breakdown:

GET /api/v1/fabric/catalog
Authorization: Bearer <token>

The catalog has three sections:

Section What it contains
operations Callable capabilities - each has an id, title, input schema, permission, and a write: bool flag
events Trigger sources - each has an event_type, payload schema, and optional produces (media type of a referenced artifact)
ai_tools Read-only projection of the AI assistant’s tool registry - what the AI can actually call

The catalog is rebuilt on demand with a 3-second TTL cache. A plugin that starts or stops becomes visible within approximately 3 seconds.

Browse it in the UI under Fabric → Catalog, or call the API directly and pipe to jq.

Every operation and event is tagged with a tier that governs how it runs:

Tier Source Trust model
native First-party in-tree modules Full trust; read ops execute directly; write ops route through staging
plugin SDK plugins SDK-bounded; permission-declared; namespaced plugin.{id}.*; write ops are categorically refused

A native operation whose id starts with plugin. is rejected at registration time (and vice versa). The namespace guard is enforced in code, not convention.

Native operation IDs follow {module}.{verb} (for example cameras.snapshot). Plugin operation IDs follow plugin.{plugin_id}.{verb}. Both must match ^[a-z0-9][a-z0-9_-]*(\.[a-z0-9][a-z0-9_-]*)+$.

Native event types follow {domain}.{entity}.{action} (for example device.status.changed). Plugin event types follow plugin.{plugin_id}.{type}.


Six modules declare native operations today. The table below lists them with their write flag and the permission the caller must hold:

Operation ID Module Write? Permission required
cameras.snapshot cameras no cameras.view
network.client.list network no network.view
voip.phone.live_status voip no voip.view
firewall.search_alerts firewall no firewall.view_logs
storage.health storage no storage.view
storage.store_blob storage yes storage.write
hypervisor.vm.snapshot hypervisor yes hypervisor.manage_snapshots
hypervisor.vm.start hypervisor yes hypervisor.manage_vms
hypervisor.vm.stop hypervisor yes hypervisor.manage_vms
hypervisor.vm.shutdown hypervisor yes hypervisor.manage_vms
hypervisor.vm.reboot hypervisor yes hypervisor.manage_vms

Hypervisor power verbs are declared as discrete operations (one per verb) because the feature key in the staging pipeline must be static; a single parameterised hypervisor.vm.power op with an action field could not route correctly.


The Fabric ships platform-wide events that are always available, plus per-module events declared by each module’s get_emitted_events() hook.

Staged-change lifecycle - payload includes change_id, controller_id, feature, operation, target_id, vendor, status, site_id, applied_at, actor_id:

Event type When it fires
controller.change.staged A write landed in the pending-changes queue
controller.change.applied A staged write was applied to the device
controller.change.failed A staged write failed at the device or applier

Device lifecycle - payload includes device_id, site_id, data.{name,old_status,new_status,reason}:

Event type When it fires
device.status.changed A managed device went online or offline
device.discovered A new device was discovered or adopted
device.updated A managed device’s record changed (rename, re-IP, firmware…)

Inbound bridge:

Event type When it fires
ingest.external Emitted by POST /fabric/ingest - see Inbound bridge below
Event type Module Artifact produced
cameras.event.motion cameras image/jpeg snapshot handle
cameras.event.person cameras image/jpeg snapshot handle
camera.status.online / camera.status.offline cameras -
camera.snapshot.captured cameras image/jpeg
network.vlan.created / .updated / .deleted network -
network.wifi.created / .updated / .deleted network -
gateway.sync.completed firewall -
gateway.brain.offline firewall -
storage.pool.degraded / storage.capacity.warning storage -
backup.validation.failed backup -
ai.budget.warning ai -

When an event produces a media-type artifact (for example image/jpeg), any downstream Connection step that declares it accepts that type receives the artifact reference automatically. The GET /api/v1/fabric/connections/suggest?source_event=cameras.event.motion endpoint returns a pre-annotated list of compatible operations.


The Fabric ships three always-available sink operations under the fabric.* namespace. They require no module to be enabled and never touch a live device:

Dispatches a multi-channel notification - email, Slack, in-app, webhook - through the same dispatch_notifications helper used everywhere else in the platform.

Param Required Notes
channels yes JSONB channel config (same shape as notification settings)
title no Truncated to 200 characters
body no Truncated to 4,000 characters

Records a structured Fabric log line. No side effects. Use it to verify Connection wiring before adding a real action.

Param Required Notes
message no Free-form string

POSTs the chain’s current payload to an external URL. This is the outbound half of the external bridge.

Param Required Notes
url yes Must be a public destination (see SSRF rules below)
method no POST / PUT / PATCH / GET - default POST
payload no Defaults to the trigger event payload if omitted
headers no Key-value dict added to the request

fabric.webhook is deliberately excluded from the AI assistant’s tool projection. The AI can never auto-POST org data to an arbitrary URL - only a human operator wires this step.

If FABRIC_WEBHOOK_SIGNING_SECRET is set, every outbound POST also carries:

  • X-Fabric-Signature: sha256=<hmac> over the exact body
  • X-Fabric-Timestamp with a replay window

The response body is capped at 16 KiB and flows into the step output so a subsequent step can branch on the result. The request timeout is 30 seconds.


The executor applies a strict two-track model based on the operation’s write flag:

Connection fires → Negotiator evaluates steps
├── write=false → _execute_native_read
│ op.handler(ctx) runs directly → result available to next step
└── write=true → _execute_native_write (STAGE ONLY)
AdapterStagingService.stage_change()
→ pending_changes row in the DB
→ result = {staged: true, change_id, feature}
operator clicks Apply in Pending Changes
live device is mutated

The Fabric never force-applies a write. There is no flag, environment variable, or Connection setting that causes a write op to bypass staging. The dual gate (ADAPTER_READ_ONLY=false AND force=true) must be explicitly satisfied by a human operator on the Pending Changes screen.

What the executor checks before staging a write

Section titled “What the executor checks before staging a write”
  1. A DB session is available (NO_DB otherwise).
  2. controller_id is present in the step params and is a valid UUID (NO_TARGET / BAD_TARGET).
  3. The target controller belongs to the Connection’s own organisation - checked via a Controller → Site join (CROSS_TENANT_TARGET). This is enforced even when controller_id is templated from an untrusted event payload.
  4. If the op accepts a media-type artifact but no artifact is present in the chain, the step fails with ARTIFACT_REQUIRED rather than staging a change that would 400 at sign-off.
  5. If the event carries a binary artifact (for example a camera snapshot), that artifact is copied from the transient broker to the durable store now, and only a small reference (durable token + SHA-256 + size) is embedded in the staged payload. Sign-off can be hours or days later; the bytes must outlive the transient broker’s 1-hour TTL.

Plugin write operations are categorically refused (PLUGIN_WRITE_FORBIDDEN). Device mutations only ever originate from a native, operator-authored path. Plugin read operations run behind a 30-second timeout, with output bounded at 256 KiB.

Calling POST /api/v1/fabric/operations/{operation_id}/invoke with a non-native operation returns 501.


Connection authoring and the permission gate

Section titled “Connection authoring and the permission gate”

Before you can create a Connection, you must be an unscoped org-admin. Each step in a Connection is validated at authoring time and again at execution time:

  • A step whose operation has permission: null is allowed only if it is a native, non-write sink (the built-in fabric.notify / fabric.log / fabric.webhook trio).
  • A step with a declared permission requires the author to currently hold that permission. A Connection executes with its author’s permissions - if the author is later deactivated or moves to a different org, the permission gate re-checks on every firing and denies.

Non-author org members can list and view Connections, but the step params (which may contain webhook URLs, Authorization headers, notification targets) are replaced with {"__redacted__": "hidden - author-only"} in list and get responses. Only the operation_id is always visible.


Step params can reference values from the trigger payload or from earlier steps using {{dotted.path}} syntax:

{
"operation_id": "fabric.webhook",
"params": {
"url": "https://n8n.example.com/webhook/freesdn",
"payload": {
"device": "{{trigger.device_id}}",
"prev_status": "{{trigger.data.old_status}}",
"snapshot_url": "{{steps.0.output.url}}"
}
}
}

Context shape:

{
"trigger": { "<trigger event payload>" },
"steps": [
{"output": {}, "artifact": null, "success": true},
"..."
]
}

Any external system can push a callback into the Fabric:

POST /api/v1/fabric/ingest
X-API-Key: <org-api-key>
Content-Type: application/json
{"name": "my_callback", "payload": {"result": "ok", "id": "abc123"}}
Property Value
Required permission event:write on the API key
Emitted event type Always ingest.external (never caller-controlled)
Name sanitisation Lowercased, [^a-z0-9_-] stripped, max 64 chars
Body cap 64 KiB → 413 if exceeded
Per-org rate limit 120 requests per 60-second window (cluster-wide via Valkey)
Response 202 {"accepted": true, "event_type": "ingest.external", "name": "my_callback"}

The event type is always ingest.external - an external caller cannot spoof a native event type. Route different callbacks by adding a condition on name in a Connection. For example, a Connection with source_event: ingest.external and conditions: name = "my_callback" fires only for that name.


The Negotiator uses Valkey (via the platform’s redis:// connection) for two cluster-wide guards:

Guard Mechanism Failure mode
At-most-once per firing SET fabric:fired:{conn}:{event} NX EX 300 Fail-open - without Redis, each gunicorn worker fires independently
Per-connection cooldown SET fabric:cooldown:{conn} NX EX {cooldown_seconds} Falls back to in-process counter

Method Path Purpose Permission
GET /api/v1/fabric/catalog Full catalog: operations + events + AI-tool projection + counts authenticated
POST /api/v1/fabric/operations/{operation_id}/invoke Invoke a single native operation over HTTP (writes stage; returns staged_change_id) per-op permission or unscoped org-admin for permissionless sinks

invoke returns 501 for any non-native operation. A permissionless native sink (fabric.notify / fabric.log / fabric.webhook) requires an unscoped org-admin. A write call returns {staged: true, change_id: "..."} - no live device is touched.

Method Path Purpose Permission
POST /api/v1/fabric/connections Create a Connection (201) unscoped org-admin
GET /api/v1/fabric/connections List Connections; step params redacted for non-authors any org member
GET /api/v1/fabric/connections/suggest Ops compatible with a source_event - annotated with match and allowed any org member
GET /api/v1/fabric/connections/{id} Get one Connection (params redacted for non-authors) any org member
PATCH /api/v1/fabric/connections/{id} Update a Connection unscoped org-admin
DELETE /api/v1/fabric/connections/{id} Delete (204) unscoped org-admin
GET /api/v1/fabric/connections/{id}/runs Run audit rows (default limit 50, max 200) any org member
POST /api/v1/fabric/connections/{id}/test Run once with an explicit payload; writes stage; records a run unscoped org-admin
Method Path Purpose Permission
POST /api/v1/fabric/ingest Emit ingest.external onto the org bus (202) event:write

Variable Default Purpose
FABRIC_ARTIFACT_DURABLE_DIR /data/fabric_artifacts Where staged-write blobs are stored until operator sign-off
FABRIC_WEBHOOK_ALLOWED_HOSTS "" (empty) Comma-separated hostnames/IPs the webhook op may reach even if private/LAN/tailnet
FABRIC_WEBHOOK_SIGNING_SECRET "" (empty) When set, every outbound webhook POST carries an HMAC signature

These are not limitations to work around - they are deliberate safety contracts:

  • Fabric writes are never auto-applied. Every write op, regardless of how it is triggered (Connection, AI assistant, direct /invoke call, or automation fabric.operation action), only stages a pending change. An operator must click Apply.
  • Plugin write ops are refused. PLUGIN_WRITE_FORBIDDEN is returned; plugin code can never touch a live device through the Fabric.
  • The AI assistant cannot call fabric.webhook. The operation is excluded from the AI bridge by design - the assistant can never auto-POST org data to an arbitrary URL.
  • Templating cannot evaluate expressions. Only dotted-path lookups. No arithmetic, no conditionals, no function calls.
  • The Negotiator and artifact broker run in the API process, not in Celery. The broker writes artifact files to the API container’s filesystem. A Celery worker in a separate container cannot access those files directly.

  • Connections - step-by-step guide to authoring event-to-action Connections
  • n8n - community node (n8n-nodes-freesdn) for bidirectional n8n integration
  • Plugin System - declare custom operations and events via SDK plugins
  • Marketplace - install signed community plugins from the marketplace

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.