Skip to main content
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) on top of Redis Streams: no extra broker, no extra service to deploy.

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: 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: 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. Per-destination behavior: Integrations.
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.

Audit log

The append-only trail the audit lane materializes

Notifications

The in-app delivery lane and its routing rules

Integrations

Slack, Telegram, ticketing, and webhook delivery

Workflows

The runs that event triggers start

Architecture overview

The tiers the bridge runs inside