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

no-bypass-by-structure

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/**: today mail (the mail sender) and supplier (the durable supplier.invoke job). 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/events and runs only inside the webhook.deliver job.
  • Use cases (usecase, ops, routes) receive only Recorder and Jobs. To send mail they can only Record an event or enqueue a job; a handler in the domain's subscriber package 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.

GateForbidsNew or existing
check-side-effects-behind-bus.shimporting 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.sha go statement outside internal/infra/** and cmd/server/** (non-test)new
check-retry-only-in-jobs.shretry.Do outside internal/infra/jobsnew (replaces the verbal rule in mail_retry.go)
check-periodic-via-scheduler.shtime.NewTicker / time.Tick outside internal/infra/jobs/**existing, updated: the scheduler is now jobs.Periodic
check-queue-behind-port.shimporting github.com/riverqueue/** outside internal/infra/jobs/river (tests included)new
check-table-event-policy.sha CREATE TABLE in schema.sql without -- events: emit or -- events: none (reason); a table declared emit without its triggernew; 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 ctxnew; 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

FileFate
routes/admin/obsidian.gothe reindex goroutine is deleted; per-note trigger events cover indexing (P1)
plugin/adapters/invoke_background.godeleted; the background supplier call is the durable supplier.invoke job (P4)
routes/hostdesk/hostdesk.go, agentcore/hostops.gothe accept loop moved into hostsocket (ListenWith starts its own loop)
plugin/mount/mounted_warm.gouses detach.Go, which owns the goroutine and absorbs its panic

Runtime and test backstops

  • Record with an undeclared type returns ErrUndeclaredType instead 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 Exposure without being listed as internal.
  • A schema parity UT (TestMigrationsAddNothingToASchemaSQLDatabase) asserts that the migrations add nothing to a schema.sql database.