Response contract once work is async

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.

A response never claims what has not happened. It promises only what is committed. Work still in flight is represented by a receipt (an event id or a job id) whose state can be read. Where a caller needs to read its own write, the request waits briefly, then falls back to the durable job.

Per side effect

Side effect What the response promises How the caller learns the rest
Search index (search right after write) corpus.create / corpus.update / corpus.promote wait up to 2 s and return indexed: true; on timeout indexed: false plus index_job_id MCP or UI reads tasks.get; an AI that sees false knows to search again later
Access-request notification "Submitted", never "the owner has been notified" The owner sees the mail job in the tasks-panel; a final discarded raises an alert
Approval mail The code (issued synchronously) plus the mail job; MCP access_requests.approve waits up to 2 s The request row shows the mail state: sending, sent or failed (completion-hooks)
Booking notification The booking succeeded (the booking row is committed); the notification state is separate Same as the access-request notification
Obsidian import The number of notes imported (committed) The per-note trigger events index each note; the Tasks panel shows the jobs
Microsite build Queued, plus the build id Waiters wake on microsite.build.settled (NOTIFY keyed by owner), no longer an in-process signal
jobs.fetch_new The listings when every source finished within 20 s; otherwise {job_ids, pending: true} jobs.fetch_result {job_ids} returns the same shape
Webhooks A write response to the owner promises no delivery The delivery log

Wait up to 2 s, then hand back a receipt

sequenceDiagram
  participant AI as owner's AI (MCP)
  participant UC as corpus.create
  participant DB as Postgres
  participant Q as Queue
  participant IX as corpus.index
  AI->>UC: create wiki
  UC->>DB: write + event (same transaction) and commit
  UC->>Q: LatestFor → AwaitFanout → Wait, up to 2 s in all
  alt done within 2 s (common)
    Q->>IX: Handle
    IX-->>Q: ok
    Q-->>UC: completed
    UC-->>AI: id + indexed: true
  else timeout (index slow or retrying)
    UC-->>AI: id + indexed: false + index_job_id
    Note over Q,IX: the job keeps running, and retries with backoff on failure
  end

The wait has three steps: find the note's latest corpus.note.changed event (LatestFor), wait for the relay to fan it out (AwaitFanout, woken by NOTIFY standmeet_events_fanned), then wait for the corpus.index job (Wait, woken by NOTIFY standmeet_job_final). Each wait is served by one shared LISTEN connection per process, not one polling connection per request. At most 64 waiters per channel; beyond the cap, the request returns indexed: false at once instead of queueing (concurrency-control). The completion signal is a pg_notify, not an outbox event (completion-hooks).

In the common case a receipt costs the index queue's poll (≤ 100 ms, concurrency-control) plus River's batch completer, which records completions every 250 ms.

The copy rule

The same rule governs copy. UI and MCP text states only confirmed facts. "Sent" appears only after the job is completed.