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.
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
sourcefixed atoasm://core-api.workspaceIdis 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.
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.
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
XAUTOCLAIMand 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
FLUSHDBremoves it. The loop detectsNOGROUPand 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.
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.Related
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
