Completion hooks: a finished job sends a notification

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.

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, discarded or cancelled sends pg_notify('standmeet_job_final', job id).
  • One shared LISTEN connection 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.discarded outbox events. Recording one per job would fan out again for every job. The job row is the durable record the panel and tasks.get read.
  • 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.
sequenceDiagram
  actor Owner
  participant UI as Admin · requests list
  participant UC as access_requests.approve
  participant Q as Queue
  participant M as approval_mail job
  participant L as pgstore.Listener (shared LISTEN)
  Owner->>UI: approve
  UI->>UC: approve
  UC->>UC: same transaction, issue code + Record(access_request.approved) + Enqueue(mail)
  UC->>L: wait on job id, up to 2 s
  Q->>M: Work
  M->>M: send, then mark the request replied
  M-->>Q: completed
  Q->>L: pg_notify standmeet_job_final(job id)
  L-->>UC: wake (or the 2 s pass, then the receipt)
  UC-->>UI: code + link + mail_job_id
  UI-->>Owner: row state sending → sent (polls the job state)

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-checked retrieval-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-notify waits for a terminal state before asserting the count.
  • The norm-outward-toolset golden gained the tasks.*, events.*, webhooks.* and jobs.fetch_result tools.

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