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:

SectionWhat it contains
operationsCallable capabilities - each has an id, title, input schema, permission, and a write: bool flag
eventsTrigger sources - each has an event_type, payload schema, and optional produces (media type of a referenced artifact)
ai_toolsRead-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:

TierSourceTrust model
nativeFirst-party in-tree modulesFull trust; read ops execute directly; write ops route through staging
pluginSDK pluginsSDK-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 IDModuleWrite?Permission required
cameras.snapshotcamerasnocameras.view
network.client.listnetworknonetwork.view
voip.phone.live_statusvoipnovoip.view
firewall.search_alertsfirewallnofirewall.view_logs
storage.healthstoragenostorage.view
storage.store_blobstorageyesstorage.write
hypervisor.vm.snapshothypervisoryeshypervisor.manage_snapshots
hypervisor.vm.starthypervisoryeshypervisor.manage_vms
hypervisor.vm.stophypervisoryeshypervisor.manage_vms
hypervisor.vm.shutdownhypervisoryeshypervisor.manage_vms
hypervisor.vm.reboothypervisoryeshypervisor.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 typeWhen it fires
controller.change.stagedA write landed in the pending-changes queue
controller.change.appliedA staged write was applied to the device
controller.change.failedA staged write failed at the device or applier

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

Event typeWhen it fires
device.status.changedA managed device went online or offline
device.discoveredA new device was discovered or adopted
device.updatedA managed device’s record changed (rename, re-IP, firmware…)

Inbound bridge:

Event typeWhen it fires
ingest.externalEmitted by POST /fabric/ingest - see Inbound bridge below
Event typeModuleArtifact produced
cameras.event.motioncamerasimage/jpeg snapshot handle
cameras.event.personcamerasimage/jpeg snapshot handle
camera.status.online / camera.status.offlinecameras-
camera.snapshot.capturedcamerasimage/jpeg
network.vlan.created / .updated / .deletednetwork-
network.wifi.created / .updated / .deletednetwork-
gateway.sync.completedfirewall-
gateway.brain.offlinefirewall-
storage.pool.degraded / storage.capacity.warningstorage-
backup.validation.failedbackup-
ai.budget.warningai-

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.

ParamRequiredNotes
channelsyesJSONB channel config (same shape as notification settings)
titlenoTruncated to 200 characters
bodynoTruncated to 4,000 characters

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

ParamRequiredNotes
messagenoFree-form string

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

ParamRequiredNotes
urlyesMust be a public destination (see SSRF rules below)
methodnoPOST / PUT / PATCH / GET - default POST
payloadnoDefaults to the trigger event payload if omitted
headersnoKey-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"}}
PropertyValue
Required permissionevent:write on the API key
Emitted event typeAlways ingest.external (never caller-controlled)
Name sanitisationLowercased, [^a-z0-9_-] stripped, max 64 chars
Body cap64 KiB → 413 if exceeded
Per-org rate limit120 requests per 60-second window (cluster-wide via Valkey)
Response202 {"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:

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

MethodPathPurposePermission
GET/api/v1/fabric/catalogFull catalog: operations + events + AI-tool projection + countsauthenticated
POST/api/v1/fabric/operations/{operation_id}/invokeInvoke 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.

MethodPathPurposePermission
POST/api/v1/fabric/connectionsCreate a Connection (201)unscoped org-admin
GET/api/v1/fabric/connectionsList Connections; step params redacted for non-authorsany org member
GET/api/v1/fabric/connections/suggestOps compatible with a source_event - annotated with match and allowedany 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 Connectionunscoped org-admin
DELETE/api/v1/fabric/connections/{id}Delete (204)unscoped org-admin
GET/api/v1/fabric/connections/{id}/runsRun audit rows (default limit 50, max 200)any org member
POST/api/v1/fabric/connections/{id}/testRun once with an explicit payload; writes stage; records a rununscoped org-admin
MethodPathPurposePermission
POST/api/v1/fabric/ingestEmit ingest.external onto the org bus (202)event:write

VariableDefaultPurpose
FABRIC_ARTIFACT_DURABLE_DIR/data/fabric_artifactsWhere 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