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

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.