Roadmap and decisions

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.

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)