> ## Documentation Index
> Fetch the complete documentation index at: https://docs.oasm.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Event bridge

> The internal publish/subscribe bus — a single Redis Stream of CloudEvents, fanned out to consumer groups for audit, projections, notifications, integrations, and project triggers

Everything that happens inside the core API — a scan finishing, an asset appearing, a member changing a permission — is published onto one **event stream**. The event bridge is the code that writes those events in and fans them out again. It is the reason the audit trail, the notification rows, the Slack/Telegram/webhook deliveries, and event-triggered workflow runs all see the same facts without any of them calling each other.

It runs entirely inside the core API ([tier 2](https://docs.oasm.io/architecture/overview.md#2-core-api)) on top of Redis Streams: no extra broker, no extra service to deploy.

```mermaid theme={null}
flowchart LR
    P["Producer<br/>controller, service, worker callback"]
    Bus[("Redis Stream<br/>oasm:events")]

    subgraph Lanes["Consumer groups — one per lane"]
        direction TB
        A["audit<br/>materializes audit_events"]
        Pr["projection<br/>read models"]
        N["notifications<br/>rows + SSE"]
        I["integrations<br/>webhook, Slack, ticketing"]
        Pj["projects<br/>scope rules + triggers"]
    end

    DLQ[("Dead-letter stream<br/>oasm:events:dlq:{workspaceId}")]

    P -->|XADD| Bus
    Bus -->|XREADGROUP| A
    Bus -->|XREADGROUP| Pr
    Bus -->|XREADGROUP| N
    Bus -->|XREADGROUP| I
    Bus -->|XREADGROUP| Pj
    A -.->|5 failed attempts| DLQ
    Pr -.->|5 failed attempts| DLQ
    N -.->|5 failed attempts| DLQ
    I -.->|5 failed attempts| DLQ
    Pj -.->|5 failed attempts| DLQ
```

## Publishing

Producers reference catalog leaves, never string names: the path *is* the handle, so an undeclared event is a compile error, and a name outside the catalog is refused and logged as a producer bug rather than dropped silently.

Two delivery postures decouple the request from the bus: a throwing publish for events that belong to the operation's contract, and a non-throwing variant whose guarantee is that a Redis outage never fails the request that happened to be emitting. Request-shaped events derive their envelopes declaratively — an interceptor builds the envelope from the request and response via configured extractors (`resourceId`, `changes`, `metadata`, …) — so emission stays co-located with the handler it describes.

### Redaction happens at the producer

A published event is transport: it lands in Redis and is then read by the audit table, customer webhooks, Slack, and ticketing systems. So payloads are **redacted once, on the way in**, rather than per sink:

* a field whose **name** looks like a credential (secret, token, password, credential, API/private/access key, authorization, bearer, passphrase, cert, SSH key) is dropped outright;
* a **value** that looks like a credential — `sk-`, `AKIA`, a PEM header, or a long delimiter-free token — is replaced with `***`, even under an innocuous key.

Redaction runs again on the audit table as defence in depth, since a stream entry can also arrive from something other than this process.

## The contract

The wire contract has four parts:

* **The catalog** — one tree, `EVENT_CATALOG`, with roughly a hundred leaves. The path *is* the name: `EVENT_CATALOG.workspace.config.updated` → `workspace.config.updated`. Naming is `<domain>.<action>` (or `<domain>.<sub_resource>.<action>`), lowercase, past tense for completions.
* **The envelope** — CloudEvents 1.0, built and validated by the CloudEvents SDK, with `source` fixed at `oasm://core-api`. `workspaceId` is mandatory: the stream carries no global scope, so every event belongs to exactly one workspace.
* **Payload shapes** — typed per event, so producers and sinks agree on the data at compile time.
* **`EVENT_SCHEMA_VERSION`** — bumped only for a breaking change (a removed or retyped field). Additive optional fields do not bump it, so a v1 consumer keeps parsing while v2 events appear on the same stream.

Stream entries are laid out in the CloudEvents **event format**: each context attribute (`id`, `type`, `source`, `time`, `subject`, …) becomes its own stream field and `data` holds the JSON payload. That is what lets a lane route on `type` without parsing the payload.

Domains in the catalog:

| Domain | Examples |
| - | - |
| Audit trail | `workspace.*`, `member.*`, `permission_group.*`, `api_key.*`, `integration.settings.updated` |
| Inventory | `asset.discovered`, `asset.url.discovered`, `vulnerability.detected`, `vulnerability.status.updated` |
| Scanning | `job.created`, `job.completed`, `job.failed`, `scan.schedule.fired`, `target.scan.completed` |
| Automation | `workflow.run.started`, `project.assets.added`, `project.run.completed` |
| Execution layer | `worker.joined`, `worker.timed_out`, `worker.network.connected` |
| Delivery | `notification.sent`, `notification.failed`, `integration.sync.failed`, `integration.ticket.created` |

Forty-five of the leaves mirror the audit actions one-for-one, so "what is auditable" and "what is on the bus" are the same list.

## Lanes

Each **consumer group** is a lane with its own read position on the stream, driven by a sink that knows what to do with an event:

| Lane | What it does | Consumes |
| - | - | - |
| `audit` | Materializes the event as a row in the audit trail | every domain, filtered by policy |
| `projection` | Keeps read models current | inventory, jobs, workflows, statistics |
| `notifications` | Writes in-app notification rows and pushes them to the console | inventory, jobs, workflows, workers, integrations |
| `integrations` | Delivers to webhooks, Slack, and ticketing systems | inventory, jobs, workflows, integrations |
| `projects` | Matches new assets against project scope rules and starts event-triggered runs | assets, projects, vulnerabilities |

Because each group tracks its own position, a slow lane never holds the others back, and one stream keeps FIFO order **across** domains within a lane. Splitting into a stream per domain would give that up for nothing.

Routing is declared in two places, on purpose:

* **`SUBSCRIPTIONS`** — which domains each lane reads. This is transport, not business logic.
* **`EVENT_POLICY`** — per event, whether it is persisted to the audit trail (`persist`) and how loudly it may notify (`severity`). It is typed as a complete record over the event names, so adding a catalog leaf without a policy fails the build instead of silently never being audited.

Content filters finer than "this domain" live in the sink itself (`accepts`), not in the routing table.

## Delivery guarantees

Delivery is **at-least-once**, and history is bounded rather than infinite:

* **Retry.** A sink that throws leaves the entry in the group's pending list. Once it has been idle for 30 seconds, another instance claims it with `XAUTOCLAIM` and retries. Stalled entries are retried **before** new ones are read, so a busy stream cannot starve a failing handler.
* **Dead-letter.** After 5 failed deliveries the envelope is copied to a per-workspace dead-letter stream, `oasm:events:dlq:{workspaceId}`, with the failing group, timestamp, and error — and then acknowledged, so one poisoned event cannot block the lane.
* **Ordering.** The audit lane must be strictly ordered, so it runs exclusively: a distributed lock elects one instance to drive it, taken per cycle rather than for the process lifetime so the lane fails over instead of getting stranded. The other lanes are safe to run on several replicas.
* **Recovery.** A consumer group lives on the stream key, so an eviction or a `FLUSHDB` removes it. The loop detects `NOGROUP` and recreates the group from the start of the stream rather than staying dead for the life of the process.
* **Retention.** Every write trims the stream to roughly 100,000 entries (`MAXLEN ~`) and the key expires after 7 days. Seven days is a lower bound on replay: anything older is read from the audit trail, which retention keeps for 90 days.

An event whose type is no longer in the catalog is acknowledged and logged, not retried forever — same for an envelope that cannot be attributed to a workspace.

## What leaves the platform

Outbound deliveries do not forward the internal envelope. Integrations receive a smaller, stable body — `type`, `workspaceId`, `occurredAt`, `outcome`, `actor`, `resourceType`, `resourceId`, `changes`, `metadata`, `payload` — because request correlation fields mean nothing outside OASM and should not leak into customer infrastructure. Payload shape per event: [Webhook](https://docs.oasm.io/integrations/webhook.md). Per-destination behavior: [Integrations](https://docs.oasm.io/integrations/overview.md).

> [!NOTE]
>
> There is no `email` lane. A group is only declared when a transport exists to deliver it — a subscription that promises delivery nobody can perform is worse than a missing feature.

## Related

> [card] [Audit log](https://docs.oasm.io/audit-log.md)
>
> The append-only trail the audit lane materializes

> [card] [Notifications](https://docs.oasm.io/notifications.md)
>
> The in-app delivery lane and its routing rules

> [card] [Integrations](https://docs.oasm.io/integrations/overview.md)
>
> Slack, Telegram, ticketing, and webhook delivery

> [card] [Workflows](https://docs.oasm.io/workflows.md)
>
> The runs that event triggers start

> [card] [Architecture overview](https://docs.oasm.io/architecture/overview.md)
>
> The tiers the bridge runs inside


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.