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.
What’s new
Section titled “What’s new”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.resolvePending -> Failed(PromiseError) via promise.signal_failPending -> Cancelled(PromiseCancelReason) via promise.signal_cancelThe 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.
Querying terminal state
Section titled “Querying terminal state”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_FAILEDlet err = promise.failure_kind[u64](p) // → promise.ERR_UPSTREAM_FAILEDNumeric code reference
Section titled “Numeric code reference”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 group | Members |
|---|---|
| PromiseState | STATE_PENDING (0), STATE_RESOLVED (1), STATE_FAILED (2), STATE_CANCELLED (3) |
| PromiseError | ERR_NONE (0), ERR_UPSTREAM_FAILED (1), ERR_SCHEMA_MISMATCH (2), ERR_NOT_RESOLVED (3), ERR_RESOLVER_DROPPED (4) |
| PromiseCancelReason | CANCEL_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:
| Code | Clause | Meaning |
|---|---|---|
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_RESOLVED | runtime | Await timeout or no value available. |
ERR_RESOLVER_DROPPED | runtime | The 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_DROPPED | runtime | The producer actor died or was passivated. |
Why this matters
Section titled “Why this matters”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.
Migration
Section titled “Migration”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.
Scope and what’s next
Section titled “Scope and what’s next”This release closes three of Phase A’s four gaps:
| Phase A gap | Status |
|---|---|
| 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_or | Audit-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.
Reference
Section titled “Reference”- SPEC-236:
Promise[T]and Pipelined Async (full specification) - SPEC-029: Reference Capabilities (sendability rules)
- Implementation commits:
5a01d43,68ab6a4c,849ecdcf,1c50b5confeature/voxis/spec-236-phase-ab