Event model: a string type, JSON, declared once
Parent: events
Status: released in v0.1.76 (2026-09-27) — design and as-built record in docs/design/event-bus-outbox-webhooks.md in the StandMeet repo.
An event is a type string plus JSON. The substrate never has a Go type per event, and every event type is declared once, as data, by the domain that owns it.
event = { id (uuid), seq, type (string), owner_id, subject (string URI or id),
occurred_at, data (json) }
idisgen_random_uuid().seqis an identity column that only orders a claim; no cursor reads it (relay-claims-rows-not-cursor).- The outbox row also carries the relay's bookkeeping:
fanned_out_at,fanout(which job each subscriber got),relay_failures,poisoned_at,last_error.
Type naming
typeis a dotted string: a noun (which may itself be dotted) then a verb. Examples:corpus.note.changed,access_request.created,microsite.build.settled.- The substrate sees a string and JSON. This is the same rule as seams: a seam is a name, a verb and JSON, never a typed contract.
- Where the event comes from (a trigger or a
Recordcall) does not show in the shape: see two-sources-of-events.
The declaration is data
| Field | Meaning |
|---|---|
Type | the dotted type string |
Description | human text |
Subject | the subject pattern, e.g. <genre>://<note path> or webhook://<endpoint id> |
Exposure | who may receive it: Internal or Webhook |
- Each domain declares its types and exposes them through its facade. The composition root collects them in
cmd/server/wire/periodic.go, one line per source. - The admin UI and the MCP
webhooks.event_typesop read the collected list. No hand-kept list exists. Recordwith an undeclared type returnsErrUndeclaredTypeinstead of writing silently. A subscription whose glob matches no declared type fails at boot.- A registry UT (
cmd/server/wire/event_types_test.go) checks every declaration: declared once, a dotted noun-then-verb name, a description, a subject pattern, and an exposure chosen on purpose.
Exposure defaults to Internal
| Value | Who receives the event |
|---|---|
Internal (zero value) | in-process subscribers only |
Webhook | may also leave the instance through webhooks |
A new event type cannot leave the instance by accident. This answers the mis-exposure risk of generated faces: a forgotten classification must never publish by default. The registry UT fails when a type is left at the zero value without being listed as internal on purpose.
The 39 declared types
All are thin and all are Webhook-exposed; none is subscribed by default.
| Source | Types |
|---|---|
| corpus (trigger) | corpus.note.changed |
| corpus (writings) | writing.published, writing.unpublished |
| owner: webhooks | webhook.test |
| access | access_request.created, .approved, .status_changed; code.issued, .revoked, .redeemed; api_key.issued, .revoked |
| conversation | conversation.started, .message, .pruned; ghost.accepted |
| owner: bookings | booking.created, .cancelled, .rescheduled |
| owner: microsites | microsite.build.settled, page.promoted_live, .rolled_back, .unpublished, microsite.store.doc_inserted |
| owner: account and vault | owner.login, .email_changed, .recovery_requested; gas.exhausted, .refilled; vault.imported |
| stats | instance.upgrade_requested |
| security | ip_ban.added |
| blocks and suppliers (block model) | block.installed, .failed; supplier.connected, .disconnected, .activated |
| job loop | application.committed, jobs.fetched |
instance.upgrade_requestedcommits in its own transaction, and only when the updater exists.owner.logincommits in its own transaction (a login writes no row; the session lives in Redis). A recovery-phrase sign-in also records it, committed with the spent phrase.jobs.fetchedis recorded once per source that fetched successfully.
Payload: thin
The payload carries the type, the subject and ids (the Stripe "thin events" shape). The consumer re-reads the resource through the API it already uses. Less data leaves the instance, and the fact stays with the producer that owns it.
Core class diagram
- The substrate knows only the
typestring and JSON. Exposuredefaults toInternal, so a forgotten classification stays inside the instance.- The embed scope reuses the single ACL predicate
entity.AllowsCorpusEntry. The composition root hands the access domain's answer to the owner domain's fan-out, so infra imports no domain.
Related: relay-claims-rows-not-cursor (how rows become jobs), queue-behind-ports (the interfaces), webhooks.