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 trigger | Domain event via Record | |
|---|---|---|
| Who writes it | Postgres, AFTER INSERT/UPDATE/DELETE | the use case, recorder.With(tx).Record(ctx, ownerID, type, subject, data) |
| When to use it | a watched row changed | a fact no single row states |
| Example | corpus.note.changed with data.op ∈ {created, updated, deleted} | access_request.created, booking.created, application.committed |
| Coverage | total by construction: a write path cannot forget to emit | explicit, acceptable because each event has exactly one producing use case |
| Proof | trigger UTs assert every watched column emits, no-op updates do not, and the payload shape | a per-use-case UT asserts the event is recorded in the write's transaction |
Row-change triggers
- Table:
corpus_notesis the only table that emits. The other tables declare-- events: none (reason); semantic facts about them (writing.published,code.issued, …) areRecordcalls. - Two triggers:
AFTER INSERT OR DELETE, andAFTER UPDATE … WHENa 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 touchesupdated_atemits 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 UTTestSQLPathSegmentMatchesGo. - Data:
op(created, updated, deleted),note_id,genre,parent_id,published,was_published(so an unpublish is visible) andpath_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
CreateWikibug class impossible, not fixed once:CreateWikiandCreateOutputnever 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).