Skip to content

RELAY

Working with Relay

How relay-edge publishes into Zyvor Relay — with or without relay-pubsub in the middle.

← Docs hub · See also Concepts · Event matrix


What relay-edge does for Relay

Relay owns the durable control loop:

Accept → Notify → Ack → Act → Verify

relay-edge owns everything before Accept:

relay-edge Relay
Sites, zones, devices, seasons Event log + policies
Stamp season/site/zone/device context into data Match on type + severity
Simulators (firewater, remote-edge, fleet) Notify recipients, run acts
Web control rooms Verify via telemetry probe

relay-edge never runs the notify/ack/act loop. It produces policy-ready events and POSTs them into Relay.


Two ways to reach Relay

                    ┌─────────────────────┐
                    │     relay-edge      │
                    │  stamp + publish    │
                    └──────────┬──────────┘
                               │
           ┌───────────────────┴───────────────────┐
           │                                       │
           ▼                                       ▼
  GATEWAY_BASE_URL set                   GATEWAY_BASE_URL empty
  (preferred in production)              (direct / dev / minimal stack)
           │                                       │
           ▼                                       ▼
  relay-pubsub                         POST {RELAY_BASE_URL}/v1/events
  POST …/topics/{type}:publish                    │
           │                                       │
           └───────────────────┬───────────────────┘
                               ▼
                         Zyvor Relay
                         Accept → …

Implementation: internal/relaypub/client.go — PublishEventType() picks the path automatically.

Path When to use Env vars
Via relay-pubsub Google Pub/Sub clients, multi-tenant gateway, k8s stack GATEWAY_BASE_URL, optional GATEWAY_AUTH_TOKEN, FASAL_GCP_PROJECT
Direct to Relay Edge-only deploy, debugging, no gateway process Unset GATEWAY_BASE_URL; set RELAY_BASE_URL + RELAY_AUTH_TOKEN

Both paths send the same logical event — type, severity, source, idempotency key, stamped data.


Direct mode — configuration

Run edge with gateway disabled:

export GATEWAY_BASE_URL=          # empty = direct path
export RELAY_BASE_URL=https://127.0.0.1:8443   # or :18080 on some lab hosts
export RELAY_AUTH_TOKEN=<jwt-from-relay>
export RELAY_TLS_INSECURE=1       # if Relay uses self-signed TLS
go run ./cmd/relay-edge

On startup, edge logs which URLs it uses:

relay-edge v0.1.1 listening on http://:18086 (data=./data gateway= relay=https://127.0.0.1:8443 tls=false)

When gateway= is empty, every publish goes straight to Relay.

systemd / deploy

scripts/deploy-remote.sh prefers systemd when passwordless sudo works; otherwise nohup. Default is gateway mode (GATEWAY_BASE_URL=https://127.0.0.1:8081). For direct-only:

# In relay-edge env file on the host (or RELAY_EDGE_DIRECT=1 on deploy):
GATEWAY_BASE_URL=
RELAY_BASE_URL=https://127.0.0.1:8443
RELAY_AUTH_TOKEN=<jwt>
RELAY_TLS_INSECURE=1

Unit template: deploy/systemd/relay-edge.service.


Direct wire contract

Edge POSTs to Relay's native events API:

POST /v1/events
Authorization: Bearer <RELAY_AUTH_TOKEN>
Content-Type: application/json

Body (produced by publishDirect):

{
  "type": "irrigation.required",
  "severity": "critical",
  "source": "FW Plant / Watch Season / grape / Zone FW-A",
  "idempotency_key": "edge/season/season_abc/open/1735123456",
  "data": {
    "season_id": "season_fw_watch",
    "season_name": "Industrial watch",
    "site_id": "site_fw_plant",
    "site": "Fire-water plant",
    "zone_id": "zone_fw_process_a",
    "zone": "FW-A",
    "device_id": "dev_fw_pump_main",
    "fasal_device_id": "fw-pump-01",
    "recipient": "fcm-token-or-demo",
    "verification_probe": {
      "url": "http://127.0.0.1:18086/v1/firewater/verify?command=pump.start",
      "method": "GET",
      "json_path": "$.ok",
      "expect": "true"
    },
    "recommended_action": {
      "target": "firewater-controller",
      "command": "pump.start",
      "payload": { "zone": "FW-A", "zone_id": "zone_fw_process_a", "device_id": "dev_fw_pump_main", "season_id": "season_fw_watch" }
    }
  }
}

Success response (edge stores event.id on seasons when applicable):

{
  "event": { "id": "evt_…", "type": "irrigation.required", "status": "accepted" }
}

The publish field in API responses shows which path was used:

{ "publish": { "path": "relay", "event_id": "evt_…" } }

Gateway path returns "path": "gateway" instead.


Gateway wire contract (for comparison)

When GATEWAY_BASE_URL is set, edge calls Google Pub/Sub-compatible REST:

POST /v1/projects/fasal-onprem/topics/irrigation.required:publish
Authorization: Bearer <GATEWAY_AUTH_TOKEN>   # optional

Body:

{
  "messages": [{
    "data": "<base64-json-of-stamped-data>",
    "attributes": {
      "severity": "critical",
      "source": "FW Plant / Watch Season / …",
      "idempotency_key": "edge/firewater/…"
    }
  }]
}

relay-pubsub (RELAY_BACKEND=relay-events) decodes this and forwards the same fields to POST /v1/events on Relay.

Topic name is the event type. Project defaults to FASAL_GCP_PROJECT (fasal-onprem).


The stamp — what edge adds before Relay sees the event

Every publish (farm API or simulator) runs through the same enrichment pipeline in internal/httpapi/server.go:

season → site → zone → device → contact (from site routing)
                              → verification_probe (from zone telemetry)

Stamped fields in data:

Field Source
season_id, season_name, crop, stage Season store
site_id, site Site store
zone_id, zone Zone store
device_id, fasal_device_id Device store
recipient, sms_recipient, email_recipient Contact via site routing
verification_probe Zone telemetry config (for Act → Verify)
recommended_action Simulator critical events (target + command)
sim_domain firewater, remote-edge, or fleet for simulator events

Farm critical events (POST /v1/seasons/{id}/events) require the season to be active.

Simulators require POST /v1/firewater/seed first — it creates the shared industrial season/site/zone/devices all simulators reuse.


End-to-end lifecycle (direct or gateway)

  ┌──────────────┐
  │  User / UI   │  Seed → scenario → Publish toggle
  │  or smoke.sh │
  └──────┬───────┘
         │ HTTP to relay-edge
         ▼
  ┌──────────────┐
  │ relay-edge   │  resolveEnrich → stampData
  └──────┬───────┘
         │
         ├── direct ──► POST Relay /v1/events
         │
         └── gateway ► POST pubsub …/topics/{type}:publish
                              │
                              ▼
                         POST Relay /v1/events
         │
         ▼
  ┌──────────────┐
  │    Relay     │  Accept (policy match on type + severity)
  └──────┬───────┘
         │ Notify (FCM/SMS/email from stamped recipients)
         ▼
       Ack (human or auto)
         │
         ▼
       Act (if critical + recommended_action)
         │  Relay POST /v1/actions → relay-pubsub Action Gateway
         ▼
       Verify (probe in stamped data)

Act wiring (when using relay-pubsub)

For critical events that should Act, Relay must know where to send actions. On the Relay process:

RELAY_ACTION_TARGETS=farm-controller=https://127.0.0.1:8081/v1/actions,\
firewater-controller=https://127.0.0.1:8081/v1/actions,\
remote-edge-controller=https://127.0.0.1:8081/v1/actions,\
fleet-controller=https://127.0.0.1:8081/v1/actions
RELAY_TLS_INSECURE=1   # required when pubsub uses self-signed HTTPS

Lab helper (rewrites targets, restarts Relay, optional new binary):

RELAY_BIN=/path/to/linux-amd64-relay ./scripts/lab-wire-relay-act.sh <HOST>
# then re-sync relay-pubsub RELAY_AUTH_TOKEN and ./scripts/e2e-stack.sh

Direct mode still benefits from relay-pubsub for the inbound action path — edge only handles outbound publish. A minimal direct-only lab can run edge → Relay without Act unless you also run pubsub for /v1/actions.


What publishes, and when

Trigger Event examples Publish path
POST /v1/seasons/{id}/open crop.advisory Always (if Relay reachable)
POST /v1/seasons/{id}/events irrigation.required, … Always (season must be active)
POST /v1/seasons/{id}/advisories weather.advisory, … Always
Firewater UI / API firewater.tank.low, edge.comms.down, … When publish: true in config
Remote-edge UI / API remote-edge.link.offline, … When publish: true in config
Fleet UI / API fleet.power.island, … When publish: true in config

Local simulation without Relay: leave publish: false — events appear in the UI SSE stream only.


Try direct mode locally

1. Start Relay (from the relay repo — port and JWT vary by your install):

# Example lab defaults:
# Relay HTTPS :8443 (212) or :18080 (175), JWT in /tmp/lab-relay.jwt

2. Start edge in direct mode:

export GATEWAY_BASE_URL=
export RELAY_BASE_URL=https://127.0.0.1:8443   # or :18080 on some lab hosts
export RELAY_AUTH_TOKEN="$(cat /tmp/lab-relay.jwt)"
export RELAY_TLS_INSECURE=1
go run ./cmd/relay-edge

3. Full direct matrix (farm + 13 firewater + 6 remote-edge + 6 fleet scenarios):

cp config/lab-direct.env.example config/lab-direct.env
# BASE, EDGE, RELAY_AUTH_TOKEN — relay-edge must have GATEWAY_BASE_URL empty

set -a && source config/lab-direct.env && set +a
./scripts/stack-probe.sh --direct
./scripts/e2e-direct-relay.sh
# or: ./scripts/e2e-direct-stack.sh

Deploy relay-edge in direct mode on a remote host:

RELAY_EDGE_DIRECT=1 RELAY_AUTH_TOKEN=<jwt> ./scripts/deploy-remote.sh <HOST> [USER]

4. Farm smoke (creates domain + publishes through edge → Relay direct):

./scripts/smoke.sh

Check the last response — publish.path should be "relay" and include event_id.

5. Simulator via UI:

# open http://127.0.0.1:18086/ui
# Seed → Publish into Relay → scenario "Low tank level"

6. Confirm in Relay:

curl -k -H "Authorization: Bearer $(cat /tmp/lab-relay.jwt)" \
  "https://127.0.0.1:8443/v1/events?limit=5"

(Exact query API depends on your Relay version — adjust path if needed.)


Auth and TLS

Variable Used on Purpose
RELAY_AUTH_TOKEN Direct path Authorization: Bearer to Relay /v1/events
GATEWAY_AUTH_TOKEN Gateway path Bearer to relay-pubsub (when auth enabled)
RELAY_TLS_INSECURE=1 Both outbound Trust self-signed Relay/gateway certs (lab)
EDGE_TLS=1 Inbound to edge Serve /ui and API over HTTPS

JWT must be issued by the same Relay instance (or trusted issuer) that receives events. In multi-service deploys, sync the same token to edge and relay-pubsub.


Event families Relay receives

All types are plain strings — Relay policies match on them:

Family Example types
Farm irrigation.required, crop.advisory, frost.alert, …
Firewater / edge firewater.tank.low, edge.comms.down, telemetry.sample, …
Remote edge remote-edge.link.offline, remote-edge.galleon.thermal, …
Fleet fleet.power.island, fleet.robot.lost, …

Full cross-family gate (gateway path): Event matrix.

Direct mode uses the same types — only the hop between edge and Relay is shorter.


Choosing a path

Need Recommendation
Google Pub/Sub SDKs, pull/subscribe, action gateway relay-pubsub (GATEWAY_BASE_URL set)
Minimal stack, edge appliance, debugging stamp payload Direct (GATEWAY_BASE_URL empty)
Kubernetes lab stack Both pods via deploy-k8s-remote.sh (gateway mode)
CI / unit tests Edge only, publish: false — no Relay required

Zynera integration (optional)

When Zynera is co-located at an edge site, Relay can use Zynera Decision Records for human-gated approvals before Act (decision_backend: forge on policy). Zynera recommends and records; Relay still executes via the Action Gateway. relay-edge does not call Zynera — it only publishes stamped events.

Typical lab wiring (configure on Relay):

RELAY_FORGE_BASE_URL=http://<zynera-host>:30631
RELAY_FORGE_API_KEY=<zynera-api-gateway-secret>

Authoritative guide in this repo → Integration guide

Quick index → Zynera at the edge