2026-09-27·by Sijie Wang#standmeet#architecture#design#events

event-model

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) }
  • 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

FieldMeaning
Typethe dotted type string
Descriptionhuman text
Subjectthe subject pattern, e.g. <genre>://<note path> or webhook://<endpoint id>
Exposurewho 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

ValueWho receives the event
Internal (zero value)in-process subscribers only
Webhookmay 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.

SourceTypes
corpus (trigger)corpus.note.changed
corpus (writings)writing.published, writing.unpublished
owner: webhookswebhook.test
accessaccess_request.created, .approved, .status_changed; code.issued, .revoked, .redeemed; api_key.issued, .revoked
conversationconversation.started, .message, .pruned; ghost.accepted
owner: bookingsbooking.created, .cancelled, .rescheduled
owner: micrositesmicrosite.build.settled, page.promoted_live, .rolled_back, .unpublished, microsite.store.doc_inserted
owner: account and vaultowner.login, .email_changed, .recovery_requested; gas.exhausted, .refilled; vault.imported
statsinstance.upgrade_requested
securityip_ban.added
blocks and suppliers (block model)block.installed, .failed; supplier.connected, .disconnected, .activated
job loopapplication.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

  • 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.

about this entry

One of sijie's wiki entries. The AI on this site is grounded in the same corpus and answers in sijie's voice, with citations back to entries like this one — answering costs sijie money, so it waits behind a code: enter an access code →