Fibric. Docs fibric.io →
v1.0.0 · stable
Concepts

The event envelope

Every event in Fibric travels as one canonical type: the EventEnvelope. A webhook from a commerce platform, a temperature reading from a gateway, a scheduled tick, and an operator’s own output all become envelopes with the same ten fields. This page is the field reference for that type, and it explains how an envelope is constructed, routed, correlated, and persisted.

Why one canonical event

The kernel routes, governs, and audits events without knowing or caring where they came from. That is only possible because there is exactly one event type. The design intent is stated at the top of envelope.ts itself: a Shopify webhook, an MQTT temperature reading, a cron tick, and an agent’s own output all become an EventEnvelope. A thermostat reading and a support message flow through identical machinery.

SaaS webhook
source: "shopify"
An order created in a commerce platform arrives over HTTP.
Hardware reading
source: "bacnet-gw-7"
A gateway publishes a zone temperature over MQTT.
Schedule
source: "cron"
A timer wakes an operator for its periodic sweep.
Operator output
source: "operator:jenny"
An operator’s own finding re-enters the system as an event.
EventEnvelope — one shape, one routing path, one audit trail

This is what makes the platform vertical-agnostic. Adding a new kind of system, whether software or hardware, means writing a connector that emits envelopes. Nothing downstream changes: the router, the trust policy, the executor, and the event log already know how to handle the shape.

Field reference

The interface below is the complete, current shape from packages/kernel/src/envelope.ts. Every field is present on every envelope; optionality is expressed with null, never with a missing key.

FieldTypeRequiredDescription
event_id string Generated Unique identifier for this envelope. makeEnvelope fills it with a UUID; you never supply it.
reseller_id string | null Yes The reseller the tenant belongs to. null means the tenant is Fibric-direct, with no reseller. The field is present on every envelope either way; it is half of the tenancy spine.
tenant_id string Yes The tenant this event belongs to. Never null and never optional. An envelope without a tenant cannot be constructed.
workspace_id string | null No The workspace within the tenant, when the event is scoped below tenant level. null when the event is tenant-wide.
source string Yes Where the event originated. Examples from the source: "shopify", "bacnet-gw-7", "cron", "operator:jenny". Operator-originated events use the operator: prefix.
event_type string Yes Dot-delimited type used for routing. Examples: "order.created", "hvac.zone.fault". Router triggers match this field with globs; see how envelopes flow.
correlation_id string Generated if absent UUID that threads a chain of cause and effect. Supply it to continue an existing chain; omit it and makeEnvelope starts a new one.
payload Record<string, unknown> Defaults to {} The event body. The kernel does not interpret it; connectors and operators do. Shape is defined by the emitting source.
agent_id string | null No Set when an operator originated the event; identifies which one. null for events from external systems and schedules.
session_id string | null No Set when the event belongs to an operator session, such as a conversation with the Operations Analyst. null otherwise.

The reseller_id and tenant_id law

Two fields carry the isolation model of the entire platform. reseller_id and tenant_id appear together on every envelope, and the same pair lands on every tenant-scoped Postgres row the envelope ever touches. The pair drives row-level security: a database session can only read or write rows whose reseller_id and tenant_id match the verified context set for that transaction.

The rule has two consequences worth stating plainly:

The full model, including the resellers → tenants → workspaces hierarchy and the RLS policies that enforce it, is covered in Tenancy & isolation.

Constructing envelopes

Envelopes are constructed through makeEnvelope, which takes an EnvelopeInput and fills in the generated and defaulted fields. The input type makes the contract explicit: tenant_id, source, and event_type are the only required inputs; everything else is generated, defaulted, or nullable.

envelope.ts
export interface EnvelopeInput {
  reseller_id?: string | null;
  tenant_id: string;
  workspace_id?: string | null;
  source: string;
  event_type: string;
  correlation_id?: string;
  payload?: Record<string, unknown>;
  agent_id?: string | null;
  session_id?: string | null;
}

export function makeEnvelope(input: EnvelopeInput): EventEnvelope {
  return {
    event_id: crypto.randomUUID(),
    reseller_id: input.reseller_id ?? null,
    tenant_id: input.tenant_id,
    workspace_id: input.workspace_id ?? null,
    source: input.source,
    event_type: input.event_type,
    correlation_id: input.correlation_id ?? crypto.randomUUID(),
    payload: input.payload ?? {},
    agent_id: input.agent_id ?? null,
    session_id: input.session_id ?? null,
  };
}

A constructed envelope looks like this. The example is a new message on a support conversation, arriving from a Kustomer connector into a Fibric-direct tenant:

json
{
  "event_id": "1f6a9c2e-8d4b-4f1a-9c3e-0b7d2a5e8f10",
  "reseller_id": null,
  "tenant_id": "t_8f2a…c901",
  "workspace_id": "acme-ops",
  "source": "kustomer",
  "event_type": "conversation.message.created",
  "correlation_id": "7d31b0aa-4c92-4e0d-8b6f-52e19a3c4d77",
  "payload": {
    "conversation_id": "cnv_64a1",
    "direction": "in",
    "channel": "email",
    "preview": "Checking in on order SO-10884, the proof was approved Monday…",
    "customer_id": "cus_2be7"
  },
  "agent_id": null,
  "session_id": null
}
i
Nullables are always present

Note that agent_id and session_id are serialized as null, not omitted. Every envelope has all ten fields, which keeps validation, storage, and querying uniform across sources.

How envelopes flow: router, trust, executor

Once constructed, an envelope moves through three kernel stages in order.

Routing by event_type

Operators register with the EventRouter against a glob trigger. EventRouter.register(trigger, agent_id, operator) records the registration; match(event_type) returns every registration whose trigger matches the envelope’s event_type.

The glob semantics are deliberately narrow: * matches exactly one dot-delimited segment, and everything else is literal. So "order.*" matches "order.created" but not "order.item.added". An operator that wants deeper events registers a deeper trigger, such as "order.item.*". There is no recursive wildcard.

router.ts
// An operator: given an event, propose a plan.
// (The LLM proposes; the executor disposes.)
export type Operator = (env: EventEnvelope) => Promise<ExecutionPlan> | ExecutionPlan;

router.register("conversation.*", "jenny", jennyOperator);
router.register("order.*",        "ship-risk", orderRiskOperator);

router.match("order.created");     // → [ship-risk registration]
router.match("order.item.added");  // → [] — "*" is one segment, not many

Propose, then dispose

A matched operator receives the envelope and returns an ExecutionPlan: a reasoning string and a list of planned actions. The operator does not act. The plan goes to the deterministic executor, which evaluates each action against the tenant’s trust policy (fail-closed: no matching policy means BLOCK), enforces single-flight per entity and idempotency, invokes what survives, and records the result. The envelope is the input to the whole chain; the receipt is its output.

Correlation and sessions

Three fields attribute an envelope to a larger story.

correlation_id threads a chain of cause and effect. When a webhook envelope wakes an operator and the operator’s output re-enters the system as a new envelope, the new envelope carries the same correlation_id. Querying the event log by that one UUID reconstructs the whole chain: what arrived, what the operator concluded, and what was done about it. When you construct an envelope without a correlation_id, a new chain begins.

Two rules of thumb keep chains useful:

agent_id and session_id attribute operator-originated envelopes. An event emitted by an operator carries the operator’s identity in agent_id (matching the agent_id under which it registered with the router) and, when the work belongs to an interactive session, the session in session_id. External events, hardware readings, and schedule ticks leave both null. Combined with a source of the form "operator:jenny", the provenance of machine-originated work is never ambiguous.

Persistence

Envelopes are persisted by PgEventStore into the event_log table, which is the exemplar tenant-scoped table: it carries reseller_id, tenant_id, workspace_id, source, event_type, correlation_id, and payload.

The write path enforces the tenancy law mechanically. The store connects as the non-superuser role fibric_app, and every transaction first sets the app.tenant_id and app.reseller_id GUCs transaction-locally with set_config(…, true), taking the values from a verified context: the envelope itself here, a verified JWT claim in production. Row-level security then constrains every read and write in that transaction to the matching tenant. A transaction without valid tenant context reads nothing and writes nothing; the failure mode is closed.

!
The envelope sets its own wall

Because the GUCs are derived from the envelope being written, an envelope can only ever land inside its own tenant’s partition. There is no code path where event data for one tenant is written under another’s context.

The persisted row keeps the routing and correlation fields as first-class columns rather than burying them in the payload. That is what makes the event log queryable as an operational record: filter by event_type to see every order created this week, by source to audit one connector, or by correlation_id to replay a single chain end to end.

Details of the schema, the RLS policies, and the role setup are in Tenancy & isolation.