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

two-sources-of-events

Two ways an event is born: triggers and Record

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 born one of two ways: a database trigger writes it when a watched row changes, or a use case calls Record for a fact no single row states. Both write the same events table in the same transaction as the change, so a consumer cannot tell them apart and does not need to.

Row-change triggerDomain event via Record
Who writes itPostgres, AFTER INSERT/UPDATE/DELETEthe use case, recorder.With(tx).Record(ctx, ownerID, type, subject, data)
When to use ita watched row changeda fact no single row states
Examplecorpus.note.changed with data.op ∈ {created, updated, deleted}access_request.created, booking.created, application.committed
Coveragetotal by construction: a write path cannot forget to emitexplicit, acceptable because each event has exactly one producing use case
Prooftrigger UTs assert every watched column emits, no-op updates do not, and the payload shapea per-use-case UT asserts the event is recorded in the write's transaction

Row-change triggers

  • Table: corpus_notes is the only table that emits. The other tables declare -- events: none (reason); semantic facts about them (writing.published, code.issued, …) are Record calls.
  • Two triggers: AFTER INSERT OR DELETE, and AFTER UPDATE … WHEN a watched column changed. Watched columns: genre, title, body, tags, parent_id, published, show_as_source, aliases, excerpt, slug, archived, css_classes, lang. An update that only touches updated_at emits nothing.
  • Subject: the note URI, computed in SQL (corpus_note_uri, corpus_path_segment). The SQL copy of the path rule is held to the Go rule by the UT TestSQLPathSegmentMatchesGo.
  • Data: op (created, updated, deleted), note_id, genre, parent_id, published, was_published (so an unpublish is visible) and path_changed (title or parent changed, so the index rebuilds the subtree).
  • The trigger also calls pg_notify('standmeet_events') to wake the relay (relay-claims-rows-not-cursor).
  • The captured fields live in one migration and in schema.sql; a schema parity UT (TestMigrationsAddNothingToASchemaSQLDatabase) keeps the two in step.
  • This makes the CreateWiki bug class impossible, not fixed once: CreateWiki and CreateOutput never called the index hook.

Decided (2026-09-26): row-change capture uses database triggers with a WHEN clause; semantic events stay explicit Record calls.

Every table must decide

The gate check-table-event-policy.sh fails when a CREATE TABLE in schema.sql has neither -- events: emit nor -- events: none (reason), or when a table declared emit has no trigger. Every new table forces a decision. It is a required declaration, not an exclusion list. As built, all 58 tables are annotated and only corpus_notes emits. See no-bypass-by-structure.

Tables (ER)

Before and after: the search index

Related: event-model (the shape both sources write), storage-bounds (the WHEN clause as a bound).

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 →