# Events

inseam's typed, durable, in-process event bus — a transactional outbox written in the same Store `batch` as the data change that produced it, plus a dispatcher that fans rows out to in-process subscribers. Plugins contribute `Listener`s (side effects) and `Materializer`s (derived projections) through an Effect-free contract.

**See also:** [design](../design/events.md) · [spec](../spec/events.md)

## What it does

Two surfaces, split by package:

- **Contract surface** (`@inseam/events-contract`) — `EventEnvelope`, `Listener`, `Materializer`, `EventsModule`, `defineEvents`, `HandlerResult` / `MaterializerResult`, `Cursor`, plus the error hierarchy (`EventsError`, `DuplicateModuleError`, `InvalidEventTypeError`, `TrustBearingTypeError`, `EmitGuardError`, `UnknownSubscriberError`). Zero runtime deps.
- **Core surface** (`@inseam/core`, Events contribution) — `openEventsRegistry`, `EventsRegistry`, `EmitHandle`, `OutboxRow`, `MaterializerHalt`, `SkipAttestation`, `EventsHostIdentity`, `EventsTuning`, `BatchBuilder`. Owns migrations, the per-origin sequence counter, the dispatcher loop, cursor + dead-letter + halt tables, and the operator-facing halt-recovery actions.

`emit` appends INSERTs to a caller-supplied `BatchBuilder`; the caller commits via `Store.batch`, so the event row and the data row land atomically. Dispatch is asynchronous: subscribers run in their own loops, advance per-origin cursors, and survive crashes by reading from the outbox.

Two subscriber roles, two contracts:

- **Listener** — side-effecting, at-least-once, sequential per `(listener, origin)`. Failures retry with exponential backoff up to `maxAttempts`, then dead-letter and advance. Handlers MUST be idempotent on `event.id`.
- **Materializer** — owns a projection. Receives ordered, gap-free delivery per origin. The projection write and the cursor advance commit in the same `ctx.tx` batch. Failures retry; exhaustion **halts** the materializer on that origin (cursor stays put) until an operator skips, resumes, or rewinds.

Trust-bearing core types (`principal.identifier_verified`, `pairing.token_*`, `host.capability_changed`, etc.) are core-only emit. Plugin emit paths are rejected with `TrustBearingTypeError`.

## How to use it

A plugin contributes events through a single `EventsModule`:

```ts
import { defineEvents, type Event, type HandlerResult } from "@inseam/events-contract";

export const gmailEvents = defineEvents({
  name: "gmail",
  listeners: [
    {
      name: "gmail-label-change-notifier",
      types: ["gmail.label_changed"],
      async handle(ev: Event): Promise<HandlerResult> {
        const ok = await notify(ev.payload);
        return ok ? { ok: true } : { ok: false, retryable: true, reason: "notify failed" };
      },
    },
  ],
  materializers: [
    {
      name: "gmail-label-cache",
      types: ["gmail.label_changed"],
      replay: true,
      async project(ev, { tx }) {
        await upsertLabel(tx, ev.payload);
        return { ok: true };
      },
    },
  ],
});
```

A host opens the registry and registers modules:

```ts
import { openEventsRegistry } from "@inseam/core";

const events = await openEventsRegistry({
  store,
  identity: { networkId, originHostId, signEnvelope },
});
events.registerEvents(gmailEvents);
```

Full type signatures: [spec — Public API](../spec/events.md#public-api).

## How to extend it

Plugins contribute exactly one `EventsModule` per registry, depending only on `@inseam/events-contract`. First-party plugins live alongside their Connection at `pkgs/plugin-<name>/`; third-party plugins publish as `inseam-plugin-<name>` and are interchangeable.

The minimum a plugin author needs:

1. Pick a `module.name` matching the plugin id. Subscribers' `types` must use `<plugin-id>.*` or `core.*` — anything else is rejected with `InvalidEventTypeError`.
2. **Own a table?** Write a `Materializer`. Earn ordered, gap-free, cursor-atomic delivery; in exchange, projection writes go through `ctx.tx` and exhausted failures halt rather than dead-letter.
3. **Side effect only?** Write a `Listener`. Pay the at-least-once tax with idempotency keyed on `event.id`. Return `{ ok: false, retryable }`; never throw.
4. To emit plugin-namespaced events, get a guarded handle to `emit` from the plugin's own subsystem wrapper. Plugins cannot emit trust-bearing core types.

Operator-facing halt recovery (`skipHaltedEvent`, `resumeHaltedMaterializer`, `rewindMaterializer`) is on the registry surface for hosts only — not plugin-callable.

## Where the code lives

- Contract package: `pkgs/events-contract/src/index.ts`
- Core surface (registry, dispatcher, halt recovery): `pkgs/core/src/events/registry.ts`
- Migrations (outbox, cursors, dead-letter, halt, skip audit): `pkgs/core/src/events/migrations.ts`
- Public re-exports: `pkgs/core/src/events/index.ts`
- Test fixtures (fake subscribers, test-registry harness): `pkgs/core/src/events/__fixtures__/`
- Behavior tests, one file per concern: `pkgs/core/src/events/*.test.ts`

## Related

- [Spec — events](../spec/events.md) — envelope shape, table schemas, behavior, acceptance criteria
- [Design — events](../design/events.md) — why one bus; inbound/outbound/peer-sync as one shape; security defaults
- [Store](./store.md) — `emit` appends to a `BatchBuilder` that the caller commits via `Store.batch`; outbox / cursor / dead-letter / halt are migrations on the Store
- [Connection](./connection.md) — Connection lifecycle events ride this port; the Connection registry is a core-internal emit caller
- [Monorepo package model](./monorepo-package-model.md) — package roles, naming, dependency direction
