Implementing the promise handlers
Five specified handlers plus one deliberate stub (promise.search) cover the entire promise surface. They share two mechanics you will apply constantly: timeout projection — when a pending promise's timeoutAt ≤ now, return a computed settled record (resolved for a timer promise, rejectedTimedout otherwise) without writing it; the onPromiseTimeout transition is what eventually writes the settlement — and idempotency (returning the current state instead of an error on a repeated operation). The full projection story is in Timeouts and projection. This chapter applies both handler by handler.
All six are specified formally in resonatehq/resonate-specification.
promise.get — P-01#
Return the promise identified by req.id, applying timeout projection if it is pending and expired.
404 promise does not exist
200 + promise promise exists (pending, projected, or already settled)If the promise is pending and timeoutAt ≤ now, do not write anything — return a projected record with:
state=resolvedfor timer promises (resonate:timer = "true"),rejectedTimedoutfor everything elsesettledAt=timeoutAt
The actual settlement write happens in the timeout sweep, not here. Projection is a read-time computation; promise.get is never the trigger for a state change.
promise.create — P-02#
This handler is the busiest in the server. It branches on whether the promise exists, whether it is already expired, and whether it carries scheduling tags.
When the promise does not yet exist#
Case A — timeoutAt > now (normal create):
- Write the promise in
pendingstate. - If the promise is external — carries
resonate:targetor is a timer promise — register a promise timeout attimeoutAt. External promises must be swept; promises without a target or timer tag are settled by explicit protocol operations and need no sweep. - If
resonate:targetis set: create a task inpendingatversion = 0.- If
resonate:delayis absent, or its parsed value is ≤now: schedule a retry timeout atnow + retryTimeoutand enqueue anexecutemessage to the target address. Work starts immediately. - If
resonate:delayis set and its value is >now: schedule a retry timeout atdelayonly — do not enqueue execute yet. The execute fires when that timeout fires. See Outbox and delivery.
- If
- Return 200 with the new promise record.
retryTimeout is a server configuration value; the default in the spec is 5000 ms (ServerConfig.retryTimeout in state.lean).
Case B — timeoutAt ≤ now (already-expired create):
The caller asked for a promise with a deadline in the past. Write it directly as settled:
state=resolved(timer) orrejectedTimedout(everything else)createdAt = settledAt = timeoutAt
If resonate:target is present, also create a task, but in fulfilled state — there is no work to dispatch on an already-expired promise. Return 200.
When the promise already exists#
Return it as-is — or projected if it is pending and expired — and return 200.
promise.create never returns 409. A repeated call with the same id returns 200 with the existing promise, regardless of whether the new request's parameters match. This surprises engineers coming from create-APIs that use 409 to signal "already exists." Here, idempotency is the default: callers can safely retry a create after a network failure, and the server absorbs the duplicate. Do not add a 409 path — the spec has none.
The durable promise specification documents an extended surface (ikc/iku/strict modes) that the reference server implements. The Lean model does not specify these modes; a conformant-to-Lean server need not implement them.
promise.settle — P-03#
Settle is a single parameterized verb: state is a parameter to the request, not a separate operation per outcome. Resolve, reject, and cancel are argument values — there are no promise.resolve or promise.reject endpoints.
404 promise does not exist
200 + promise promise settled (or already settled / projected)The write path — pending and unexpired#
When the promise is pending and timeoutAt > now:
- Snapshot the promise's current
listenersandcallbackslists. - Write the promise: set
state = req.state,value = req.value,settledAt = now, and clearcallbacksandlistenersto empty. - Remove the promise timeout (
delPromiseTimeout). A settled promise has no deadline to sweep. - If a task exists for this promise id: transition it to
fulfilled, clearpid,ttl, andresumes, and remove its timeout (delTaskTimeout). - Run the settlement scrub (see below).
- Enqueue an
unblockmessage to every address in the snapshotted listeners list. See Outbox and delivery. - Call
enqueueResumefor each id in the snapshotted callbacks list.
The settlement scrub#
After a promise settles, it can never wake another awaiter — but other pending promises may still have its id in their callbacks lists. The scrub removes those stale registrations:
-- From P-03-promise.settle.lean
modify fun s =>
{ s with promises := s.promises.map fun q =>
if q.state == .pending then
{ q with callbacks := q.callbacks.filter (· != p.id) }
else
q }Walk every promise in the store. For each one that is still pending, filter the settled promise's id out of its callbacks list. Non-pending promises are left alone.
Why this matters for storage design. The scrub is a cross-record write — one settlement event triggers a deletion across an unbounded number of rows in whatever table backs promise callbacks. A naive relational schema where callbacks are stored as a JSON array on the promise row makes this an UPDATE ... SET callbacks = ... for every pending promise. A separate callbacks join table reduces this to a single DELETE FROM callbacks WHERE awaiter_id = :settled_id, which scales. The choice of schema here has a measurable effect on settle latency at scale. See Storage strategies for the tradeoffs.
enqueueResume#
enqueueResume (00-resume.lean) transitions the awaiter's task based on its current state:
suspended: move the task topending, setresumes = [awaitedId], schedule a retry timeout atnow + retryTimeout, and enqueue anexecutemessage to the awaiter'sresonate:target— but only if the awaiter promise exists and itsresonate:targettag is non-empty; an absent or empty target sends nothing (the state change still happens). The awaiter is now claimable again.pending,acquired, orhalted: the task is already active; appendawaitedIdto itsresumeslist (deduped). No message is sent — the task will read the updated resumes when it next interacts with the server.fulfilled: no-op. The awaiter is done.
Note that enqueueResume does not check the awaiter promise's deadline. Deadline enforcement on the awaiter happens through the task's own lifecycle — an expired awaiter's own settlement fulfills its task.
Idempotency#
If the promise is already settled (any non-pending state), return it with 200. If it is pending but timeoutAt ≤ now, apply timeout projection and return 200. In both cases no write occurs and no messages are enqueued.
The reference server (oracle.rs, trigger_settlement) implements the same three phases — fulfill the task, process callbacks, notify listeners — through trigger_fulfilled, trigger_callbacks, and trigger_listeners respectively. This is corroboration, not the definition.
promise.register_callback — P-04#
P-04-promise.register_callback.lean
A callback is an awaiter promise id. Registering one tells the server: when the awaited promise settles, run enqueueResume for the awaiter.
404 awaited promise does not exist
422 awaiter promise does not exist, OR awaiter lacks resonate:target
200 + promise awaited promise (pending, projected, or settled)Validate in this order:
- Look up
req.awaited. If absent, 404. - Look up
req.awaiter. If absent, 422. - Check that the awaiter promise carries
resonate:target. If not, 422. A callback without a target address is useless —enqueueResumehas nowhere to send the execute message, so the server rejects the registration upfront rather than silently losing the wake-up.
If the awaited promise is pending and timeoutAt > now, and the awaiter is also pending and timeoutAt > now: add req.awaiter to the awaited promise's callbacks list (deduped via addCallback).
If registration is skipped, the response is still 200 with the awaited promise — but the two skip cases look different to the caller. If the awaited promise is already settled (or expired, and therefore projected settled), the caller sees the settled state and knows to resume immediately. But if the awaited is still pending and only the awaiter's condition failed (expired or non-pending), the caller receives a 200 carrying a pending awaited promise with no signal that the registration was dropped. An SDK must not treat a pending-state 200 from promise.register_callback as proof the callback landed.
Both the awaited and the awaiter must be pending and unexpired for a registration to land. If the awaited is pending but the awaiter has already expired, the callback is silently skipped — a dead awaiter has no task to resume. This is intentional: the settlement scrub (P-03) handles the reverse direction (settling the awaited removes its id from other promises' lists), so missing this registration causes no permanent leak.
promise.register_listener — P-05#
P-05-promise.register_listener.lean
A listener is an address for unblock message delivery. Where a callback wakes a durable awaiter through the task system, a listener delivers a direct notification to any subscriber — an external process, a gateway, a polling client.
404 awaited promise does not exist
200 + promise awaited promise (pending, projected, or settled)If the awaited promise is pending and timeoutAt > now: add req.address to the promise's listeners list (deduped via addListener). The address will receive an unblock message when the promise settles, either via promise.settle (P-03) or the timeout sweep.
If the promise is already settled or expired: return the current or projected state without registering. The caller can read the settlement value directly from the response — no notification will come.
There is no 422 here. Any address string is accepted; the server does not validate that the address resolves to a reachable subscriber at registration time. See Outbox and delivery for delivery semantics.
promise.search — P-06#
501 not implemented — unspecified surfaceThe Lean spec returns 501 unconditionally. Search semantics — filter shape, pagination, index requirements, sort order — are unspecified. See Unspecified surface.
The reference server implements a search endpoint with tag-based filtering and cursor pagination. That behavior is the reference server's design, not a spec requirement. If you implement search, use the reference server as a practical starting point, but do not treat its behavior as normative.
Verified against resonate-specification@83d64c3.