Nothing can bypass the bus: structure first, gates second

Updated · View the entry on sijie.xyz ↗

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

classDiagram
  direction LR
  class UsecaseDeps {
    «request path · usecase / ops / routes»
    +Recorder events
    +Jobs enqueue
    +Repos …
    no mail port / supplier write
  }
  class SubscriberDeps {
    «job path · internal/<domain>/subscriber»
    +mail.Sender
    +Repos …
  }
  class SideEffectPorts {
    «internal/infra/sideeffect/**»
    mail · Sender
    supplier · supplier.invoke job
  }
  class WebhookDelivery {
    «infra/events · run only by webhook.deliver»
    sign · send · classify
  }
  class QueryPorts {
    «sync queries, allowed on the request path, sent once»
    Calendar free_busy
    OAuth / Captcha / model listing
  }
  UsecaseDeps ..> QueryPorts
  SubscriberDeps ..> SideEffectPorts
  SubscriberDeps ..> WebhookDelivery
  UsecaseDeps ..x SideEffectPorts : forbidden by gate
  • 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.

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

  • 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.