Consolidation inventory: 101 items
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.
A full sweep of backend, app, sdk, builder, im-bridge, infra/plugins and the updater found 101 items of hand-wired "after X, also do Y", and each one got exactly one target. Targets: E emit an event plus a subscriber, J durable job, P periodic job on River, W exposed as a webhook event type, K keep. Row numbers refer to the sweep (as of origin/main 6eb8c0a64). The "As built" column says where each move landed. Phases are in events-roadmap.
pie showData title Where the 101 items go (an item may count in several) "E emit event" : 22 "J durable job" : 22 "P periodic job" : 9 "W webhook type" : 23 "K keep" : 44
Moves: after-write side effects
| # | What | Where it was | Failed by | Target | As built |
|---|---|---|---|---|---|
| 1 | Access request → email the owner | access_requests.go:59 → request_notify.go:44 |
blocked the request through retries; lost on failure; Redis throttle dropped silently | E access_request.created + J |
owner.notify (owner/subscriber/mail.go); the 5-per-hour burst cap is a Postgres slot and stays a deliberate drop |
| 2 | Approve request → mail the code + mark replied | access_approval.go:86-89 |
mail sent but status not written = inconsistent | E + J | access_request.approval_mail enqueued with the code in one transaction; replied written after the send |
| 4 | Email-change confirmation | email_change.go:138 |
failed send left a dangling pending row | J | owner.email_confirmation; the link token is minted at send time |
| 5 | Booking → notify owner | booker-mcp.js:458 → invoke_background.go:53 |
detached goroutine, lost on restart | E booking.created + J |
the booking.record host op writes booking.* and a booking_notices row in one transaction; owner.notify sends and deletes it |
| 7 | Booking persist failed → delete calendar event (compensation) | booker-mcp.js:309,337 |
failed delete left an orphan event, never retried | J | durable supplier.invoke |
| 8–10 | Meili upsert / delete / reindex after publish | corpus_crud.go, subjectivity.go, corpus.go:139, output.go:61, seo.go:126,140 |
skipped calls, slow requests, process-local dirty flag | E corpus.note.changed + J |
corpus_notes trigger + corpus.index (P1) |
| 11 | Full rebuild after Obsidian import | obsidian.go:115,133 |
goroutine, lost on restart | J | per-note trigger events; goroutine deleted (P1) |
| 14 | Build settled → auto-publish homepage | builds.go:242 |
failure only logged, never retried | E microsite.build.settled + J |
owner.SettleBuild writes the build row, the event and pg_notify('standmeet_build_settled', owner) in one transaction; subscriber microsite.homepage_publish |
| 15 | Build settled → recompute asset refs | builds.go:247,256 |
same | E + J | subscriber microsite.asset_refs |
| 16 | Build settled → wake preview long-poll | builds.go:193 → buildnotify |
in-process broadcast, broke with replicas | E (LISTEN/NOTIFY) | pgstore.Listener keyed by owner; the version is the durable ms timestamp of the owner's latest settle; infra/buildnotify deleted |
Moves: goroutines, periodic jobs, calls inside requests
| # | What | Where it was | Failed by | Target | As built |
|---|---|---|---|---|---|
| 22 | InvokeBackground (background supplier call + retry) | invoke_background.go:49-65 |
lost on restart, no dead letter, single process | J supplier.invoke |
internal/infra/sideeffect/supplier; invoke_background.go and notifyPolicy deleted (P4) |
| 23 | Obsidian rebuild goroutine | obsidian.go:135 |
same as #11 | J | deleted (P1) |
| 30 | The periodic scheduler itself | periodic.go:47-92 |
ran on every replica; state in memory | P | River periodic jobs (leader, run on start); the in-process scheduler deleted (P1) |
| 31 | Meili 8 s reconcile | corpus_index_periodic.go:40 |
relied on a process-local flag | delete | deleted (P1) |
| 32–36 | public conversation prune, gas refill, resume-draft sweep, traffic retention, usage cleanup | each *_periodic.go |
ran on every replica | P | declared as data, run on River (P1) |
| 38 | Boot-time Meili index + full backfill | wire/search_index.go:22 |
slowed boot | J | corpus.reindex enqueued at boot (P1) |
| 39, 62 | Job-source fetch (all sources serially inside the request) | sources_write.go:133, jobfetch.go:149 |
slow; could hit the 30 s write timeout | one J per source + E jobs.fetched |
jobs.fetch_source on the fetch queue; jobs.fetch_result. No scheduled fetch: job-loop.md rejects an automatic daily fetch (P4) |
| 63 | Outbound mail (retry.Do inside the request) |
mail_retry.go:49, outbound_sender.go:103 |
request blocked through backoff | J (covers #1, #2, #4) | mail port internal/infra/sideeffect/mail; internal/infra/retry deleted (P4) |
| 98 | Mail throttle | mailthrottle.go:82 |
dropped silently | throttle hit snoozes | the per-recipient cap (30/h) snoozes to the next window (P4) |
| 101 | Background-jobs list in the monitor panel | jobreg_registry.go:24 |
reset on restart | reads River jobs | JobRegistry deleted; instance.jobs reads River (tasks-panel) (P1) |
Client polling → pushed events (Phase 5, not built)
| # | Polling | Where | Interval | Target |
|---|---|---|---|---|
| 50 | Microsite list long-poll | use-microsites.ts:127 |
held request | SSE |
| 51 | Poll a build until settled | use-microsites.ts:290 |
1.5 s | SSE |
| 52 | Sidebar access-request badge | use-sidebar-badges.ts:39 |
60 s | SSE |
| 54 | Wait for Google Calendar after OAuth | use-gcal.ts:161 |
1 s × 15 | SSE |
| 56 | SDK recovery of a dropped turn | agent-adapters.ts:186 |
1 s × 6 | SSE |
Webhook event types (all thin; none subscribed by default)
The sweep listed 23 rows. As declared they are 37 types, plus corpus.note.changed and webhook.test: 39 in all, every one Webhook-exposed (event-model). See webhooks for delivery.
| # | Events | Phase |
|---|---|---|
| 75–76 | access_request.created / .approved / .status_changed |
P2 |
| 77–78 | code.issued / .revoked / .redeemed |
P2 |
| 79–82 | conversation.started / .message / .pruned, ghost.accepted |
P2 |
| 83 | booking.created / .cancelled / .rescheduled |
P4 |
| 84–85 | application.committed, jobs.fetched |
P4 |
| 86–88 | writing.published / .unpublished, corpus.note.changed, vault.imported |
P2 |
| 89–90 | microsite.build.settled, page.promoted_live / .rolled_back / .unpublished, microsite.store.doc_inserted |
P4 |
| 91–93 | api_key.issued / .revoked, supplier.connected / .disconnected / .activated, block.installed / .failed |
P2 |
| 94–97 | gas.exhausted / .refilled, instance.upgrade_requested, owner.login / .email_changed / .recovery_requested, ip_ban.added |
P2 |
Keep (K)
| Kind | Items |
|---|---|
| Writes into the same database that are themselves the source of truth | #12 cross-link rebuild, #13 import receipt, #17 block-failure record, #19 inference usage, #20 dialog, card and ghost writes, #21 seen job ids |
| Process plumbing and servers | #24–#29 |
| Per-node sandbox cleanup | #37 |
| The updater's file signal and polling (it deliberately does not touch the database) | #44–#46 |
| Telegram long-polling and in-memory im-bridge sessions | #48–#49 |
| External calls whose result a visitor needs on the spot (now sent once, no in-request retry) | #64 calendar, #66 media fetch, #67 model listing, #68 spec validation, #71 OAuth, #72 captcha, #73 system probes, #74 inference |
| Session and cache invalidation | #99–#100 |
| Waiting for restart after an upgrade (the server is down during the restart) | #55 |
| Traffic recording (decided) | #18 |
Open items, resolved as proposed
| # | Item | Resolution | Why |
|---|---|---|---|
| 3 | Recovery-phrase email | K, synchronous | The user waits on the login page and must learn on the spot whether it was sent |
| 6 | Visitor booking confirmation email | K, synchronous | The visitor needs the confirmation now; the compensating delete moves to J (#7) |
| 41–43 | Build queue (hand-written SKIP LOCKED + lease) | K for the queue; its settle hooks go through E | The builder is a cross-process Node sidecar and the queue is already durable; little gain from moving it |
| 47 | im-bridge polls its config every 15 s | K; SSE considered in P5 | The config rarely changes |
| 53 | System info refreshed every 1 s | K | Live metrics are naturally polled |
| 57 | Visitor tools "appear on the next poll" | find the caller before P5 | The sweep did not find the client call site |
| 59–61 | PDF render for application commit, draft, public report | K, synchronous | The user wants that PDF now; commit renders first on purpose |
| 69 | Marketplace install | K | The result is needed on the spot |
| 18 | Traffic recording | K | Decided |
| 75–97 | Webhook exposure | all subscribable, none by default, all thin | These are facts of the owner's own instance; security events suit alerting |
Fixed along the way (not in the plan)
- SDK hydration:
BlockWidgetanduse-chat-sessionreadlocalStoragein the first render (React #418 on prerendered microsites); they now read it after mount. - A data race on
plugin/mountknownToolSpecs; MeiliIndextakes a fresh index handle per call; Tasks detail acted on a stale job after switching rows. - Three places where the wiki was right and the code wrong:
schema.sqllackedvisit_event/visit_viewer(found by the schema parity UT); a stale Typst comment indrafts_edit.go; the manual-upgrade copy named a nonexistentSTANDMEET_REDEPLOY_HOOK(8 locales).
Four of the moved operations had callers that waited for the result; their completion signals are in completion-hooks and async-response-contract.