[@layout]: ../docs.layout.md

# Events

Events describe Plugin activity. Core validates data and distinguishes committed
Actions, server observations, and browser observations.
[Analytics](../hosting/analytics.reference) can persist selected activity.

---

## Emit from a server Plugin

Create `paths/lesson.page.md` with a heading, then save this complete Plugin as
`plugins/entry.plugin.ts`:

```ts
import { defineEvent, definePlugin, route, type PluginContext } from "@pathmx/core"
import { z } from "zod"

const Visit = defineEvent({
  data: z.object({
    code: z.string().max(160),
    destination: z.string().max(2048),
    source: z.string(),
  }),
  track: { properties: ["code", "destination"], source: "source" },
})

export default function EntryPlugin() {
  let events: PluginContext["events"]
  return definePlugin({
    id: "entry",
    events: { visit: Visit },
    setup(ctx) { events = ctx.events },
    sourceRoutes(source) {
      if (source.path !== "/lesson.page.md") return []
      return [route.redirect("/start", {
        source: source.id,
        async onRedirect({ source, destination }) {
          await events.emit(Visit, {
            code: "start",
            destination: destination.pathname,
            source: source.id,
          })
        },
      })]
    },
  })
}
```

A GET to `/start` redirects to the admitted lesson and publishes `entry.visit`.
The Plugin works with or without Analytics. HEAD requests do not emit the visit.
`ctx.events.emit(definition, data)` returns `Promise<void>` after validation and
publication; it does not wait for subscriber persistence. The definition must be
registered by the emitting Plugin. Core supplies the id, timestamp, and server
provenance. Each Plugin factory owns its context reference.

---

## Record an Action outcome

Inside an [Action's](./actions.reference) `run({ tx, events })`, call
`events.record(definition, data)` after staging the domain change. The Action
recorder validates all staged Events before committing and publishes them only
if the commit succeeds. Its Event definition must be registered by a Plugin.
Use domain Events for actual state changes; a successful no-op need not repeat
a change Event.

Core also publishes `action.completed` after every successful Action invocation,
including successful no-ops. It contains `{ action: "plugin.action" }` and uses
the same Action origin. Its Analytics collection default is disabled; an explicit
`events: { "action.completed": true }` override enables invocation counts.

Events share this envelope:

```ts
type EventOrigin =
  | { kind: "action"; actor: ActorId; operation: string; target?: string }
  | { kind: "server"; plugin: string }

// PathMXEvent<Data>
{ id, type, occurredAt, origin, data }
```

Only Action origins contain a trusted Actor and operation. Caller-provided browser
fields cannot become Action provenance.

---

## Select safe tracking properties

`defineEvent({ data, track: true })` enables count-only tracking by default.
A tracking object selects top-level scalar properties and, optionally, a Source
field for public admission and `analytics: false` exclusion:

```ts
track: {
  enabled: false,
  properties: ["code", "destination"],
  source: "source",
}
```

Omitting `enabled` means true. `enabled: false` leaves collection off while making
the declared properties available for explicit repository opt-in. An Event with
no tracking declaration is count-only if explicitly enabled. Property keys are
checked against the schema; objects and arrays cannot be tracking properties.
There are at most 16 selected properties, with strings bounded to 2,048 characters
and finite numeric values. Invalid projections skip collection and report an error.

The Source field is resolved for admission; it is not automatically a custom
property. Emit canonical public paths without query strings or fragments. Choose
safe fields deliberately: private responses, uploads, prompts, and freeform answers
need not become Analytics properties.

---

## Contribute from a browser interaction

Register a browser definition in the producing Plugin:

```ts
const ArtistSaved = defineEvent({
  data: z.strictObject({ source: z.string(), persisted: z.boolean() }),
  browser: true,
  track: { source: "source", properties: ["persisted"] },
})

// In a Plugin with id "guide":
events: { artist_saved: ArtistSaved }
```

Call this from the Plugin's browser client after one user action changes state:

```ts
import { observe } from "@pathmx/core/runtime"

observe("guide.artist_saved", {
  source: artistSourceId,
  persisted: savedToBrowserStorage,
})
```

`observe(name, data)` returns void and never throws into the interaction. Without
an enabled observation sink it does nothing and does not queue for later setup.
Emit at the originating state change, rather than at renderer updates or storage
synchronization notifications. Each document, including a same-origin dialog,
owns its own observations.

Browser definitions use strict flat objects: strings, booleans, finite numbers,
scalar enums, and optional or nullable scalar fields. They require a nonoptional
string Source field. String length and inclusive numeric bounds are supported;
transforms, refinements, nested data, and string formats are rejected. Browser data
has at most 16 fields and 4 KiB, with strings limited to 2,048 characters. These
same supported bounds are checked in the browser and at server ingress. Public
metadata includes enabled browser definitions without Zod or private server schemas.

A `browser: true` definition cannot also be recorded by an Action or server emitter.
Browser observations remain anonymous and do not enter the trusted Core journal.

---

## Subscribe and inspect

`ctx.events.subscribe(listener)` supplies future `EventRecord[]` and returns an
unsubscribe function. Each record contains `event`, `cursor`, `recordedAt`, and an
optional resolved `tracking` declaration. Subscriber failures are logged and
isolated. Core does not await asynchronous subscribers; each consumer must bound
its own pending work.

`ctx.events.definitions()` returns readonly `{ id, definition }` metadata for all
registered Events, including definitions with no observations. CLI inspection also
provides it through `(await ctx.inspect()).events.definitions()`.

`ctx.events.records()` returns the retained journal window.
`records(afterCursor)` throws when a cursor is expired or belongs to another host
process. The journal retains at most 1,000 records and 4 MiB, with a 64 KiB serialized
data limit per server or Action Event. It is process-local memory; durable reporting
belongs to a subscriber such as Analytics.

---

## Durable consumers

Process-local Event subscribers do not survive server restarts. Register a
[durable job](./jobs.reference) for work that must be admitted with the originating
Action and resumed later. Trusted server and Plugin CLI `ctx.events.emit` also
await durable admission when a job host is active. Events remain domain facts;
jobs track the work caused by them.
