Nothing can bypass the bus: structure first, gates second
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.
Future code cannot express a side effect that bypasses the bus: structure makes it unrepresentable, and gates only stop someone from bringing the capability back. The bus itself is described in event-model; the gate family it joins is mechanical-guardrails; the layering it extends is backend-domain-modules.
Structure: the request path holds no side-effect capability
- Every port that affects the outside world lives under
internal/infra/sideeffect/**: todaymail(the mail sender) andsupplier(the durablesupplier.invokejob). A new kind of side effect adds its port there and is governed automatically. - Webhook sending is not a port a use case can reach. It lives in
internal/infra/eventsand runs only inside thewebhook.deliverjob. - Use cases (
usecase,ops,routes) receive onlyRecorderandJobs. To send mail they can onlyRecordan event or enqueue a job; a handler in the domain'ssubscriberpackage sends it. - Synchronous queries whose result is needed on the spot (calendar free/busy, OAuth, captcha, model listing) are not side-effect ports and stay available to the request path. They are sent once; see retry-has-one-owner. The split is by the tree a port lives in, so there is no exclusion list.
Gates
Six gates run as event-bus-gates in the backend make lint; check-periodic-via-scheduler.sh runs as its own lint target.
| Gate | Forbids | New or existing |
|---|---|---|
check-side-effects-behind-bus.sh | importing internal/infra/sideeffect/** from anywhere but internal/<domain>/subscriber, internal/infra/** and cmd/server/** | new; subscriber joins the check-domain-layering order, between usecase and facade (subscriber may use usecase and repo, not the reverse) |
check-no-bare-goroutine.sh | a go statement outside internal/infra/** and cmd/server/** (non-test) | new |
check-retry-only-in-jobs.sh | retry.Do outside internal/infra/jobs | new (replaces the verbal rule in mail_retry.go) |
check-periodic-via-scheduler.sh | time.NewTicker / time.Tick outside internal/infra/jobs/** | existing, updated: the scheduler is now jobs.Periodic |
check-queue-behind-port.sh | importing github.com/riverqueue/** outside internal/infra/jobs/river (tests included) | new |
check-table-event-policy.sh | a CREATE TABLE in schema.sql without -- events: emit or -- events: none (reason); a table declared emit without its trigger | new; all 58 tables are annotated and only corpus_notes emits. A required declaration, not an exclusion list. |
check-tx-only-via-pgstore.sh | .Begin(, BeginTx( or BeginFunc( outside internal/infra/pgstore; transactions open only through pgstore.InTx and travel as an explicit parameter (repo.With(tx)), never in ctx | new; see code-structure |
Related: the queue gate keeps River behind queue-behind-ports; the retry gate enforces retry-has-one-owner; the goroutine gate enforces concurrency-control; the table gate makes every table choose whether it is a source in two-sources-of-events.
No permanent self-tests. The owner rule is that a gate is a gate: no self-test script is kept beside it. Each of the seven gates was shown red once on a planted violating sample in a scratch copy before it landed.
Violations cleared before the goroutine gate landed
| File | Fate |
|---|---|
routes/admin/obsidian.go | the reindex goroutine is deleted; per-note trigger events cover indexing (P1) |
plugin/adapters/invoke_background.go | deleted; the background supplier call is the durable supplier.invoke job (P4) |
routes/hostdesk/hostdesk.go, agentcore/hostops.go | the accept loop moved into hostsocket (ListenWith starts its own loop) |
plugin/mount/mounted_warm.go | uses detach.Go, which owns the goroutine and absorbs its panic |
Runtime and test backstops
Recordwith an undeclared type returnsErrUndeclaredTypeinstead of writing silently. A subscription whose glob matches no declared type fails at boot.- Idempotency is covered by one registry-driven UT (
cmd/server/wire): it iterates every registered subscriber, delivers the same event twice to each, and asserts a single effect. It fails for a subscriber it does not know how to drive; there is no list to maintain. - The event-type registry UT fails when a type is undeclared, badly named, has no subject pattern, or is left at the zero
Exposurewithout being listed as internal. - A schema parity UT (
TestMigrationsAddNothingToASchemaSQLDatabase) asserts that the migrations add nothing to aschema.sqldatabase.