2026-09-27·by Sijie Wang#standmeet#architecture#design#events

completion-hooks

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.

OperationWho waitedCompletion signal as built
Approval email with the codeAdmin showed "emailed to the requester" or the error; MCP access_requests.approve returned the resultThe 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 confirmationToast "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_newMCP returned the fetched listings; admin awaited the POSTOne 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 writeretrieval-search-consistency.spec.ts asserts a hit right after the writeThe 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.

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

about this entry

One of sijie's wiki entries. The AI on this site is grounded in the same corpus and answers in sijie's voice, with citations back to entries like this one — answering costs sijie money, so it waits behind a code: enter an access code →

completion-hooks