Response contract once work is async
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.
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
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.