Skip to content

v2026.6.25: SPEC-236 Phase A — Promise Sendability and Terminal States

v2026.6.25: SPEC-236 Phase A — Promise Sendability and Terminal States

Section titled “v2026.6.25: SPEC-236 Phase A — Promise Sendability and Terminal States”

The :cluster profile’s Promise[T] primitive gains two production-grade safety features this release: compile-time rejection of non-sendable payloads at the actor/grain boundary, and runtime terminal states beyond Resolved.

These are the first three landed slices of the four-phase SPEC-236 implementation (Phase A local slots, Phase B .call() sugar, Phase C SBI PromiseId layout, Phase D pipelined remote calls). Phase A is now functionally complete: the affine-distinct resolver type ships its first detection slice (explicit-typed let/var copies), with full binding-state tracking deferred to v0.2.

Compile-time rejection: Promise[ref T] cannot cross actor boundaries

Section titled “Compile-time rejection: Promise[ref T] cannot cross actor boundaries”

Per SPEC-236 [PROM:4.5], a Promise[ref T] SHALL NOT cross actor or grain boundaries. A message field of type Promise[ref T] or PromiseResolver[ref T] is now rejected at declaration time with diagnostic P236-E003:

file.jan:28:11: error: P236-E003: message 'BadMsg' variant 'Get' field 'reply' \
has Promise payload 'ref u64' that is non-sendable across actor/grain \
boundary (SPEC-236 [PROM:4.5])

Promise[ref T] is still legal inside a single actor for local async composition — only the boundary crossing is forbidden. The check fires at message-field declaration so the rejection is reported with the field source location, not at the send call site.

Compile-time rejection: PromiseResolver cannot be copied

Section titled “Compile-time rejection: PromiseResolver cannot be copied”

Per SPEC-236 [PROM:3.2.1], the resolver right is affine — it MAY be moved, but it SHALL NOT be copied. A let or var binding whose declared type is PromiseResolver[T] and whose initializer is a bare identifier is now rejected at compile time with diagnostic P236-E002:

file.jan:31:9: error: P236-E002: PromiseResolver copy — binding 'r2' \
duplicates resolver right held by 'r1' (SPEC-236 [PROM:3.2.1]; \
resolver is affine, use move semantics)

The check is syntactic: it fires when the explicit type annotation names PromiseResolver[...] and the initializer is a bare identifier. This catches the most common copy pattern with zero false-positive risk. Patterns that require type inference (untyped let r2 = r1) or binding-state tracking (duplicate call args, duplicate message fields) land in v0.2.

Runtime terminal states: Failed and Cancelled join Resolved

Section titled “Runtime terminal states: Failed and Cancelled join Resolved”

Per SPEC-236 [PROM:3.1.2], a promise transitions from Pending to exactly one of three terminal states:

Pending -> Resolved(T) via promise.resolve
Pending -> Failed(PromiseError) via promise.signal_fail
Pending -> Cancelled(PromiseCancelReason) via promise.signal_cancel

The single-transition rule [PROM:3.1.3] is enforced at the runtime layer: once a promise leaves Pending, further resolve/fail/cancel calls are no-ops (return 0). The original terminal state and its code are preserved.

std.cluster.promise now exposes:

use std.cluster.promise as promise
let p: Promise[u64] = promise.new[u64]()
let r: PromiseResolver[u64] = promise.resolver[u64](p)
// Producer signals failure.
_ = promise.signal_fail[u64](r, promise.ERR_UPSTREAM_FAILED)
// Consumer observes.
let s = promise.state[u64](p) // → promise.STATE_FAILED
let err = promise.failure_kind[u64](p) // → promise.ERR_UPSTREAM_FAILED

The codes are exposed as pub const ... : u8 because Janus does not yet support as[Enum](integer) casts at this surface. They mirror the runtime constants in runtime/cluster_bridge.zig and form a stable wire contract.

Constant groupMembers
PromiseStateSTATE_PENDING (0), STATE_RESOLVED (1), STATE_FAILED (2), STATE_CANCELLED (3)
PromiseErrorERR_NONE (0), ERR_UPSTREAM_FAILED (1), ERR_SCHEMA_MISMATCH (2), ERR_NOT_RESOLVED (3), ERR_RESOLVER_DROPPED (4)
PromiseCancelReasonCANCEL_NONE (0), CANCEL_PRODUCER_DROPPED (1), CANCEL_BY_AUTHORITY (2), CANCEL_BUDGET_EXCEEDED (3)

The error and cancel codes correspond directly to the SPEC-236 normative clauses:

CodeClauseMeaning
ERR_UPSTREAM_FAILED[PROM:5.3.1]A promise this one depended on failed; dependent pipeline segments inherit this failure.
ERR_SCHEMA_MISMATCH[PROM:6.3]Schema fingerprint mismatch between producer and consumer types.
ERR_NOT_RESOLVEDruntimeAwait timeout or no value available.
ERR_RESOLVER_DROPPEDruntimeThe producer dropped the resolver right without resolving.
CANCEL_BUDGET_EXCEEDED[PROM:7.2]The promise’s scheduling budget was exhausted.
CANCEL_BY_AUTHORITY[PROM:7.4]An explicit cancellation authority withdrew the promise.
CANCEL_PRODUCER_DROPPEDruntimeThe producer actor died or was passivated.

Before this release, Promise[T] in :cluster had only success-path semantics. Awaiting a promise that would never resolve returned a fallback value with no signal that anything had gone wrong. A misconfigured Promise[ref T] could silently leak a local reference across the actor boundary.

With Phase A landed:

  • The compiler proves at build time that no message type carries a Promise[ref T] payload. The most common Promise misuse class becomes a compile error, not a runtime bug.
  • Producers can signal typed failure. Consumers can distinguish “promise succeeded with value V” from “promise failed with error E” from “promise was cancelled for reason R” — without ad-hoc sentinel-value conventions.

Existing code that uses Promise[T] for typed request/response is unaffected. The new diagnostic only fires on message field declarations involving ref T payloads inside Promise[...] / PromiseResolver[...], which were already semantically broken.

Existing promise.resolve[T] / promise.await_or[T] calls continue to work unchanged. The new state-query functions (state, failure_kind, cancel_reason) and transition functions (signal_fail, signal_cancel) are additive — code that doesn’t call them sees no behavior change.

This release closes three of Phase A’s four gaps:

Phase A gapStatus
Compile-time sendability on Promise payloads (P236-E003)✅ Landed
Promise terminal states (Failed / Cancelled)✅ Landed
PromiseResolver[T] affine-distinct type✅ Slice 1 landed (explicit-typed let/var copy reject). Slice 2 (binding-state tracking) pending v0.2.
Scheduler-yield audit on await_orAudit-only: current exponential-backoff polling is acceptable for v0.1.0; true condvar park/wake is v0.2 work gated on SPEC-098

The remaining Phase A.2 work (binding-state tracking for indirect copies through call args, message literals, and untyped lets) is type-system work that lands in v0.2. Phase B (.call() sugar) starts after Phase A closes. Phase C (SBI PromiseId layout) is being drafted in parallel as an extension to SPEC-039.

Phase D (pipelined remote calls, distributed runtime) remains deferred indefinitely — it depends on the broader cluster distributed-runtime work that has not started.