Two ways an event is born: triggers and Record
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)
erDiagram
owners ||--o{ events : "owns"
owners ||--o{ webhook_endpoints : "configures"
embeds |o--o| webhook_endpoints : "update hook"
access_codes ||--o| embeds : "exposed by"
events ||--o{ river_job : "fans out to"
webhook_endpoints ||--o{ river_job : "delivery jobs"
corpus_notes ||..o{ events : "trigger writes"
events {
uuid id PK
bigint seq "orders a claim, never a cursor"
timestamptz fanned_out_at "null = not yet fanned out"
jsonb fanout "subscriber and job per target"
timestamptz poisoned_at "set after 5 relay failures"
text type
uuid owner_id FK
text subject
timestamptz occurred_at
jsonb data
}
webhook_endpoints {
uuid id PK
uuid owner_id FK
text url
text_array event_types
bytea secret_enc
uuid embed_id FK "nullable"
bool enabled
text disabled_reason
timestamptz failing_since "cooldown and 5-day disable"
timestamptz busy_until "per-endpoint lease"
}
river_job {
bigint id PK
text kind "subscription name or job kind, e.g. corpus.index, webhook.deliver"
jsonb args "event_id, endpoint_id"
text state
int attempt
jsonb errors
}
Before and after: the search index
sequenceDiagram participant C as corpus.CreateWiki participant DB as Postgres participant IX as Meili rect rgba(200,80,60,0.08) note over C,IX: Before: every write path had to remember the hook C->>DB: INSERT corpus_notes note over C: forgot to call indexNoteHook note over IX: not searchable, maybe fixed by the 8 s reconcile end rect rgba(80,140,90,0.08) note over C,IX: After: the trigger guarantees coverage C->>DB: INSERT corpus_notes DB->>DB: trigger → events DB-->>IX: relay → corpus.index → upsert end
Related: event-model (the shape both sources write), storage-bounds (the WHEN clause as a bound).