Event model: a string type, JSON, declared once
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
classDiagram
direction LR
class Event {
+uuid id
+bigint seq
+string type
+uuid owner_id
+string subject
+time occurred_at
+json data
+time fanned_out_at
+time poisoned_at
}
class EventType {
«declaration»
+string Type
+string Description
+string Subject
+Exposure Exposure
}
class Exposure {
«enumeration»
Internal ← zero value
Webhook
}
class Recorder {
«infra/events»
+With(tx) Recorder
+Record(ctx, ownerID, type, subject, data)
}
class RowTrigger {
«postgres trigger»
corpus_notes AFTER I/D, U WHEN changed
}
class Relay {
«loop in every process»
+FanOut(ctx) «SKIP LOCKED, ≤ 200 rows»
}
class Subscription {
«data»
+string Name
+string[] Types «glob»
+bool Coalesce
+Handle(ctx, Event) error «idempotent»
}
class WebhookFanout {
«subscription · owner domain»
globs, scope, one delivery per endpoint
}
class WebhookEndpoint {
+uuid id
+string url
+string[] event_types
+secret_enc
+uuid embed_id?
+bool enabled
+time failing_since
+time busy_until
}
class Delivery {
«webhook.deliver job»
+uuid endpoint_id
+uuid event_id
}
class EmbedAdmits {
«access domain, handed in»
AllowsCorpusEntry(scope, entry)
}
class Embed
EventType "1" --> "*" Event : types
EventType --> Exposure
Recorder ..> Event : writes in caller tx
RowTrigger ..> Event : writes in same tx
Relay --> Event : claims rows with fanned_out_at null
Relay ..> Subscription : one job per match
WebhookFanout --|> Subscription
WebhookFanout ..> EmbedAdmits : embed-attached endpoints
WebhookFanout ..> Delivery : enqueues, unique by args
Delivery --> WebhookEndpoint
WebhookEndpoint "0..1" --> "1" Embed : scope from its code
- 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.