Event model: a string type, JSON, declared once

Updated · View the entry on sijie.xyz ↗

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) }
  • id is gen_random_uuid(). seq is 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

  • type is 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 Record call) 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_types op read the collected list. No hand-kept list exists.
  • Record with an undeclared type returns ErrUndeclaredType instead 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_requested commits in its own transaction, and only when the updater exists.
  • owner.login commits 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.fetched is 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 type string and JSON.
  • Exposure defaults to Internal, 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.