Roadmap and decisions
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.
The bus shipped in phases P0 to P4; P5 is not scheduled. Each phase was test-first: its e2e specs were seen red on the unchanged code (make test-asis) before any implementation. The webhook sink is a set of routes on the external mock (mock-stack/job-board/webhook_sink.go): it records every request with its headers and body, can be told to fail or delay (/__mock/set_delay), and sits in EGRESS_ALLOW_HOSTS for e2e only. What exists is in events-test-plan.
Shipped: the full-suite acceptance passed on 2026-09-27; the release, v0.1.76, is live on sijie.xyz since 2026-09-27.
Still open: the standmeet.com deploy and the P3 real-environment check (a note edit shows on standmeet.com within 60 s).
flowchart LR
P0["P0 adopt River ✓<br/>spike"] --> G0{{"River's own migrator at boot<br/>start/stop with server ctx"}}
G0 --> P1["P1 the bus ✓<br/>index as first consumer<br/>periodic moves to River"]
P1 --> G1{{"MCP-created wiki is searchable<br/>index survives Meili outage + restart<br/>bulk import within bound"}}
G1 --> P2["P2 webhooks ✓<br/>endpoints · signing · retry · log"]
P2 --> G2{{"signature verifies<br/>published slice only (sentinel)<br/>3rd attempt succeeds after 500×2"}}
G2 --> P3["P3 embed update hook<br/>backend ✓ · standmeet.com built, not deployed"]
P3 --> G3{{"open: note edit shows on the<br/>live page within 60 s<br/>no deploy"}}
G1 --> P4["P4 consolidate the rest ✓"]
P4 --> G4{{"mail arrives despite failed first send<br/>booking during restart still notifies<br/>call sites net deletion"}}
G3 --> P5["P5 later<br/>plugin subscriptions · SSE · activity feed"]
G4 --> P5
| Phase | Content | Acceptance (e2e, black-box) | Status |
|---|---|---|---|
| P0 | Adopt River v0.47 | River's own migrator (jobsriver.Migrate) runs at boot right after pgstore.Migrate; schema.sql does not copy River's DDL; the worker starts and stops with the server context |
done |
| P1 | events table + corpus_notes trigger + relay + subscriber registry; corpus.index replaces the 9 hook calls, the SEO reindex and the Obsidian goroutine; the 8 s reconcile loop and the dirty flag are deleted; periodic jobs move to River; the Tasks panel |
events-index-via-bus, events-bulk-import-bound, tasks-panel, tasks-panel-more, upgrade-events-outbox |
done |
| P2 | endpoints table, ops, admin section, signing, SSRF guard, retry schedule, cooldown, auto-disable, delivery log, re-deliver, webhook.test; all 39 types exposed |
webhooks, webhook-event-types, events-fault-injection |
done |
| P3 | the embed form's update hook; cards gain updated_at; standmeet.com KV + /api/corpus-hook |
embed-update-hook (backend); edit a note on sijie.xyz → within 60 s the standmeet.com page updates, no deploy (real environment, recorded) |
backend done; standmeet.com deploy and the 60 s real-environment check open |
| P4 | access-request mail, approval mail, email confirmation, booking notification (booking.record), supplier.invoke, build-settled signal, per-source job fetch → all through the bus or durable jobs |
events-side-effects-durable, events-build-settled; the call-site diff is net deletion |
done |
| P5 | plugin subscriptions (cordis ctx.on), activity feed as an event projection, live admin updates over SSE, IM push |
— | not scheduled |
P3 is the embed-update-hook; P2 is webhooks; what each phase moved is itemized in consolidation-inventory. Phase 1 also carries the storage-bounds and the tasks-panel.
Decided (2026-09-26)
The owner adopted every recommendation.
| Decision | Outcome | Why |
|---|---|---|
| Queue implementation | River, behind the Jobs / Inspector / Runtime interfaces (queue-behind-ports) |
Proven in production; no new service (why-not-a-broker); MPL-2.0 is compatible with AGPL; swappable later without upper-layer changes |
| Row-change capture | Database triggers with a WHEN clause; semantic events stay explicit Record calls (two-sources-of-events) |
Coverage is total by construction; a skipped call cannot happen |
| Payload | Thin (type, subject, ids) | Nothing private leaves the instance; one source of truth |
| Off the bus | Monitor and traffic recording | High volume, no consumer; moving it would double the write load |
| Reconcile loops | None: no cron on standmeet.com, and the Meili 8 s loop is deleted | Outbox plus durable jobs plus panel alerts already guarantee delivery or make failure visible. A reconcile loop hides unreliable delivery; with reliable delivery it is not needed (message-loss-guarantees). |
| Open inventory items | Resolved as proposed in consolidation-inventory | — |
Decided during the build
| Decision | Outcome | Why |
|---|---|---|
| Coalescing | Per-subscription opt-in; only corpus.index coalesces |
The index re-reads current state; a webhook or mail delivers a fact, and merging two facts loses one (relay-claims-rows-not-cursor) |
| Relay | A loop in every process plus a 1-minute sweep, not a River job | SKIP LOCKED prevents double fan-out without a leader; a frequent periodic job would fill river_job; the sweep covers a lost NOTIFY |
| One delivery per endpoint | A lease row (busy_until), not pg_try_advisory_xact_lock |
An advisory transaction lock would hold a transaction across the POST (concurrency-control) |
| Owner-notify burst cap | Stays a drop (5 per owner per hour), now a Postgres slot | A flood must not mail the owner each time; requests stay visible in admin; a retry keeps its slot |
| Synchronous supplier calls | Sent once; the in-request retry.Do is gone |
One retry owner; a visitor-facing call answers fast (retry-has-one-owner) |
| Completion | pg_notify on terminal state; no job.completed outbox events |
One outbox event per job would fan out again for every job (completion-hooks) |
| Fetch result | jobs.fetch_result returns the listings; tasks.get shows state only |
The Tasks ops stay generic |
| Scheduled fetch | None | docs/design/job-loop.md rejects an automatic daily fetch |
| Gate self-tests | None kept; each gate shown red once on a planted sample | A gate is a gate (no-bypass-by-structure) |