Completion hooks: a finished job sends a notification
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.
Every job that reaches a terminal state sends a Postgres notification. Callers that used to wait for a synchronous result wait for that notification instead, up to a limit, and then hand out a receipt. Business state follows completion, never the enqueue.
Who waited, and what they get now
A code check of the 13 operations being moved found 4 whose callers waited for the result. Going async, each needed a completion signal.
| Operation | Who waited | Completion signal as built |
|---|---|---|
| Approval email with the code | Admin showed "emailed to the requester" or the error; MCP access_requests.approve returned the result | The code is issued synchronously and the access_request.approval_mail job is enqueued in the same transaction (mail_job_id). The row shows request-mail-state: sending, sent or failed. The request is marked replied only after the send succeeds (via UpdateAccessRequestStatus). MCP waits up to 2 s, then returns the receipt. |
| Email-change confirmation | Toast "Confirmation sent to X" | The pending row is written synchronously with pending_email_job_id; the toast says "queued"; the pending row shows the send state. The owner.email_confirmation job mints the link token at send time, so a stale job sends nothing. |
jobs.fetch_new | MCP returned the fetched listings; admin awaited the POST | One jobs.fetch_source job per source. MCP waits up to 20 s and returns the listings if done; otherwise {job_ids, pending: true}. jobs.fetch_result {job_ids} returns the full shape; tasks.get shows state only. |
| Visitor-side search (Meili) reading its own write | retrieval-search-consistency.spec.ts asserts a hit right after the write | The receipt of corpus.create, corpus.update and corpus.promote carries indexed and index_job_id; the request waits up to 2 s (async-response-contract) |
Nobody waits on the rest: the access-request notification, booking notification, compensating delete, Obsidian rebuild, post-build hooks and boot backfill. The recovery-phrase email waits for its result and stays synchronous by decision.
Mechanism: a terminal state is a notification, not an event
- A job entering
completed,discardedorcancelledsendspg_notify('standmeet_job_final', job id). - One shared
LISTENconnection per process (pgstore.Listener) wakes the waiters of that id. At most 64 wait at once; past that the caller gets its receipt immediately. - There are no
job.completed/job.discardedoutbox events. Recording one per job would fan out again for every job. The job row is the durable record the panel andtasks.getread. - UI status chips poll the job state for now (SSE in P5).
- The queues a request waits on (
index,notify) poll every 100 ms. A waiter usually has its answer within that poll plus River's 250 ms completer batch (concurrency-control). - Business state follows completion. A state such as "replied" is written by the job handler after the send, never before.
Existing specs updated
Rule: change only when a spec looks, never what it asserts. Synchronous assertions became waits or polls with the same expected outcome.
- Approval and mail:
mail-supplier,mail-throttle-recipient,admin-requests(the 400 with no mail supplier stays synchronous),access-request-notifies-owner(the exact count waits for terminal states). - Email change and recovery:
account-email-change-needs-confirmation,account-email-pending-lifecycle,account-recovery-row-tells-the-truth,account-edit. - Retrieval:
retrieval-search-consistency; re-checkedretrieval-acl,corpus-grep,subjectivity-not-cited,corpus-search-cjk-not-silent. - Job fetch: the
job-fetch-*specs,integration-job-loop,application-status-persist,admin-listings-dedup. booking-owner-notifywaits for a terminal state before asserting the count.- The
norm-outward-toolsetgolden gained thetasks.*,events.*,webhooks.*andjobs.fetch_resulttools.
The Tasks panel e2e (tasks-panel-more) covers it: after approval the row goes "sending" → "sent"; with the mail mock failing first it shows "failed", then "sent" after the retry; jobs.fetch_new past its wait limit returns a receipt, and jobs.fetch_result returns the result in the same shape (events-test-plan).