Skip to content

v2026.7.21: SPEC-236 Phase B — ActorRef.call() Promise Sugar

v2026.7.21: SPEC-236 Phase B — ActorRef.call() Promise Sugar

Section titled “v2026.7.21: SPEC-236 Phase B — ActorRef.call() Promise Sugar”

The :cluster profile now ships the ergonomic half of local request/response. Where Phase A made Promise[T] and PromiseResolver[T] safe and complete, Phase B makes the common pattern a single expression:

let p: Promise[u64] = typed_ref.call(QueryMsg.Get {})

That is the CapTP-style answer without the ceremony of manual promise construction.

.call() on typed actor and grain references

Section titled “.call() on typed actor and grain references”

Per SPEC-236 [PROM:8.1], .call(...) on an ActorRef[Msg] or GrainRef[Msg] returns a Promise[T] when the message variant declares a PromiseResolver[T] reply field.

The compiler desugars:

let p = typed_ref.call(QueryMsg.Get {})

into the same mechanism as the manual Phase A path:

let p = promise.new[u64]()
let reply = promise.resolver[u64](p)
_ = typed_ref.send(QueryMsg.Get { reply: reply })
// expression value is p

You do not pass the reply field yourself. The compiler injects it. Passing reply: explicitly is rejected with P236-E007.

SituationDiagnostic
Variant has no PromiseResolver[T] fieldP236-E007 — no result type
User supplies the reply field in .call()P236-E007 — reply is auto-injected
Receiver is not a typed ActorRef/GrainRefP236-E007 — typed ref required

Example:

file.jan:37:28: error: P236-E007: .call() requires a message variant with a
PromiseResolver[T] reply field; variant has no result type 'Ping'

Fire-and-forget unit variants (Ping, Stop) still use .send(), not .call().

{.profile: cluster.}
use std.cluster.local as cluster
use std.cluster.promise as promise
message QueryMsg {
Get { reply: PromiseResolver[u64] },
Stop,
}
@mailbox(capacity: 2)
actor Store(msg: QueryMsg) do
var value: u64 = 41
receive do
QueryMsg.Get { reply } => do
_ = promise.resolve[u64](reply, value + 1)
end,
QueryMsg.Stop => do
return 0
end,
else => do
end,
end
end
pub func main() -> i32 do
let sys = cluster.local_new(1 as u64, cluster.STRATEGY_ONE_FOR_ONE, 1 as u64)
let actor_ref = Store_start_supervised_ref(sys, 0 as u64, cluster.POLICY_PERMANENT)
let typed_ref: ActorRef[QueryMsg] = actor_ref
let p: Promise[u64] = typed_ref.call(QueryMsg.Get {})
_ = cluster.local_ref_drive_once(actor_ref)
let answer = promise.await_or[u64](p, 0 as u64, 0 as u64)
// answer == 42
return 0
end

Phase A remains the floor under Phase B:

FeatureDiagnostic / surface
Promise[ref T] cannot cross boundariesP236-E003
Affine PromiseResolver (no copy)P236-E002
Failed / Cancelled terminalsstate / signal_fail / signal_cancel
Await does not spinnanosleep backoff ([PROM:7.3])

See also the Phase A release notes.

Terminal window
./scripts/zb test-cluster-promise-call-sugar
./scripts/zb test-cluster-promise-call-no-result-reject
./scripts/zb test-cluster-promise-request-response

Landed on unstable as e5f4655e (2026-07-21).

  • Existing .send(...) code is unchanged.
  • Prefer .call() for request/response variants that carry PromiseResolver[T].
  • Keep .send() for fire-and-forget unit variants.
SPEC-236 phaseStatus
A — local Promise slots✅ Complete
B — .call() sugar (local)✅ Complete (this release)
C — SBI PromiseId layout✅ Complete — Phase C notes
D — pipelined remote callsDeferred (distributed runtime)

Promise-on-promise pipeline segments ([PROM:8.2]) are Phase D, not Phase B.