تخطَّ إلى المحتوى

Domain events

هذا المحتوى غير متوفر بلغتك بعد.

Domain events are the decoupling mechanism that lets features communicate without direct dependencies. Instead of feature A calling feature B, feature A publishes an event and feature B subscribes to it.

The DomainEventBus is an in-process broadcast publish/subscribe bus. Publishers call publish(event); subscribers consume a typed on<T>() stream. Every event implements DomainEvent and carries occurredAt.

The bus runs inside cc_server. Every publisher and every live subscriber lives there. No client sees this bus. What a client sees is a curated subset the server re-emits as notifications/* JSON-RPC frames.

Without events, every cross-feature reaction is an import. The notification path would have to import the agents feature, the pull-request feature, ticketing, and meetings just to know when to raise a toast — and each of those would then have to know that notifications exist. Ticketing would import pipelines so a completed ticket could advance a run. The dependency graph closes into a knot, and the shared kernel stops being shared.

Publishing an event breaks that. The notification path subscribes to twenty-one event classes and imports none of the features that raise them. The pipeline trigger dispatcher subscribes to the whole bus and knows nothing about pull requests, tickets, or meetings. Observability aggregates run outcomes without reaching into dispatch.

The cost is the usual one: a publisher cannot tell whether anyone is listening, and a listener that is never constructed is silently inert. TicketRemoteSyncHandler is the standing example: it is declared, covered by tests, and never wired in production.

It is worth being precise about one thing the bus does not own.

The audit trail is not a projection of the event stream. Every mutating RPC operation is audited by default — RepoOp.audited defaults to true and an operation opts out only by declaring audited: false, a deliberate act visible on the operation itself. The dispatcher appends the “who did what, from where” row directly after a successful call. There is no DomainEventAuditBridge. ActivityLogPersister writes activity_log rows from ActivityLogged events; that is a separate feed from the RPC audit.

The event bus is the transport for notifications and for automation. It is not the bookkeeping system of record.

  • Notifications. Twenty-one event classes are turned into notifications/* wire frames by the server, recorded into a durable per-workspace feed and broadcast to connected sessions. A frame whose workspace_id names a workspace the session user is not a member of is dropped on the server; the client then filters the remainder to the workspace it is looking at.
  • Event-driven automation. Pipeline triggers subscribe to the bus and auto-start matching pipelines (see below).
  • Lifecycle reactions. Creating a workspace seeds its CEO and specialist agents, the built-in pipeline templates, and the starter eval suites. A completed agent run resumes a suspended pipeline step, closes a task, feeds the goal supervisor, can wake a named checker, and can re-trigger a team leader. A merged PR or a deleted space triggers worktree garbage collection.
  • Cross-vendor sync. Local ticket changes are pushed out to configured vendors by a coordinator listening for five ticket events.

Events span the whole product surface. At a high level:

  • Workspaces, agents and repos — workspace creation, run completion, repo registration, skill updates
  • Pull requests — publishing, status changes, merges, review requests, mentions, authored-PR watch, externally detected PRs
  • Messaging — messages, spaces and space provisioning progress
  • Tickets — create, assign, reassign, details, status and the three terminal outcomes
  • Tasks — sequenced lifecycle frames for one dispatched run, so clients can order and de-duplicate them
  • Pipelines — terminal run events only (completed, cancelled, failed)
  • Observability — activity-log entries and budget thresholds
  • Identity and membership — member added, removed, role changed
  • Calendar and meetings — auth expiry, meetings starting and recordings finishing
  • Rigs — take-over, reap and unexpected close

Memory, orchestration, plan documents, artifacts, approvals, ticket-sync intake, calendar refresh, worktree merges, user creation, invites and device revocation are not domain events; see the reference.

For the complete catalog — all 53 concrete event classes, their payloads and the 24 subscribers that consume them — see the Domain events reference. That page is the inventory; this one is the model.

Live access control rides two different mechanisms

Section titled “Live access control rides two different mechanisms”

Membership and device events look symmetrical and are not, which matters if you are reasoning about how fast a revocation takes effect.

Revoking a device does terminate its sessions. The server watches the paired device table and drops any live session whose device left the active set, within seconds.

Removing a member does not close the socket. WorkspaceMemberRemoved is consumed: LocalRpcServer and RemoteRelayHost drop that user’s attached workspace subscriptions, and RemoteEventForwarder invalidates its membership cache so further frames for that workspace are not pushed. What denies the next RPC is still the role gate, which re-resolves membership on every call — so their next request is refused with “Not a member of this workspace”. The effect is immediate for anything they try to do; it just is not achieved by hanging up on them.

Pipeline triggers subscribe to domain events and auto-start matching pipelines. PipelineTriggerDispatcher listens to the whole bus, then:

  1. Short-circuits unless the event type is one it knows how to map to a payload
  2. Looks up enabled triggers matching that event type, across every workspace
  3. Filters each to its own workspace and applies its payload match filter
  4. Starts a run for each trigger that survives

The important constraint is step 1. The bus carries 53 event types; the add-trigger picker starts a run for these fourteen (EventPayloadMapper payload branches): ExternalPrDetected, PullRequestPublished, PullRequestStatusChanged, PrMerged, MessageReceived, TicketAssigned, TicketCompleted, TicketFailed, TicketCancelled, BudgetThresholdCrossed, RepoAdded, MeetingRecordingStopped, SkillUpdated and SpaceDeleted. Nothing else can start a pipeline.

RemoteEventForwarder translates the notification-class events into wire frames and pushes them over RPC to connected sessions, where a frame mapper turns them into in-app notifications. A thin client therefore sees a projection of the server’s event stream without owning any execution — which is the whole point of the thin-client split.

Not every pushed frame is a notification, though. The task-lifecycle stream and ticket reassignment are forwarded live to drive UI, but they are not recorded into the notification feed and raise no toast.