ADR-0084: Markers for Send and Sync

Status

Implemented

Summary

Give every type a single thread-safety classification on the trichotomy Unsend < Send < Sync and infer it structurally as the minimum over a type's members. Primitives are intrinsically Sync; raw pointers (Ptr(T) / MutPtr(T)) are intrinsically Unsend. User types get three new override markers in the ADR-0083 registry: @mark(unsend) (always-safe downgrade), @mark(checked_send) (claim Send despite a structural Unsend), and @mark(checked_sync) (claim Sync despite a structural Send/Unsend). The checked_ prefix flags the upgrade markers as user-asserted ("compiler can't verify; you take responsibility") — Rust's unsafe impl Send ergonomics in directive form. To give the markers immediate teeth, this ADR also adds a minimal @spawn(fn, arg) → JoinHandle(R) intrinsic that requires its argument and return to be at least Send, and a linear JoinHandle(R) built-in with a join(self) → R method.

Updating prelude containers (Vec(T), String, Option(T), etc.) so that Vec(i32) infers as Sync rather than Unsend (the naive result of containing a MutPtr(T)) is out of scope — those are each one-PR follow-ups that consume this ADR's marker mechanism via comptime if over @thread_safety(T) in the prelude (see Future Work). Future Mutex(T) and Arc(T) follow the same pattern.

Context

ADR-0083 stabilized the @mark(...) directive and the closed BUILTIN_MARKERS registry, deliberately seeding it with only the three posture markers (copy / affine / linear). Its Future Work section flagged thread-safety as a natural extension. Two forces make this the right time to land it:

  1. Concurrency is on the roadmap. ADR-0011 explicitly notes "When Gruel adds threading, need mutex or thread-local arenas?" The compiler is single-threaded today, but every primitive that follows (Mutex, Arc, channels, statics) needs a way to talk about thread safety in the type system. Building Send/Sync now means future ADRs slot in instead of re-litigating the model.
  2. Affine ownership already encodes most of the work. There is no Rc-equivalent, no interior mutability, no implicit aliasing. The only existing Gruel construct that breaks thread safety is the raw pointer — and that's confined to checked blocks. So the negative space starts small (one type), and the trichotomy can lean on what the type system already knows.

Why a trichotomy instead of two orthogonal axes

Rust models thread safety as two axes:

  • T: SendT can be transferred across thread boundaries.
  • T: Sync&T can be shared across threads.

In practice almost every interesting type is either both Send + Sync or neither. The Sync + !Send case (e.g. MutexGuard) is exotic and arguably a design wart. For Gruel — which has affine ownership and scope-bound references, so the "shared &T across threads" pattern won't even surface for a long time — collapsing the two axes into a single ladder simplifies everything: one field on each type, one min operation for inference, one marker namespace, one @spawn check.

The trichotomy:

LevelMeaning
UnsendCannot cross thread boundaries.
SendSafe to move across threads (transferring ownership).
SyncSafe to share across threads (subsumes Send).

Strict ordering: Unsend < Send < Sync. A Sync type is also Send. The minimum-of-members rule is well-defined.

If the Sync + !Send case ever becomes important, the model can grow to two axes later — this ADR closes no doors that aren't already closed by Rust convention.

Why the "checked_" naming convention for upgrade markers

ADR-0083's posture markers are all assertions about structurally verifiable facts: @mark(copy) errors if any field is non-Copy; @mark(linear) is contagious upward, never wrong. Send/Sync upgrade markers are different — they encode user claims that the compiler cannot verify. A struct holding a MutPtr(T) is structurally Unsend, but the user might know the pointer is owned-exclusive and the type is actually safe to send. That's an unsafe-style claim; the compiler trusts the user.

Naming the markers checked_send and checked_sync (rather than send and sync) signals this asymmetry in the markup itself. The checked_ prefix is a naming convention only — it does not require a checked { ... } block on the declaration. This keeps @mark(...) declaration-time-only, consistent with how every other marker behaves.

The opt-out marker is just @mark(unsend) (no prefix) because downgrading is always safe — no claim is being made beyond "restrict me further."

Container updates are out of scope

Vec(T) is a prelude function (prelude/vec.gruel) whose internal field is a MutPtr(T). Under the structural rule, Vec(i32) would infer as Unsend because of that pointer. Matching Rust (Vec<T>: Sync iff T: Sync, via unsafe impl<T: Sync> Sync for Vec<T>) is the right answer, but it requires Vec's prelude to override the default — exactly what @mark(checked_sync) and friends are for.

Updating Vec is mechanical and isolated: wrap the existing struct body in a comptime if over @thread_safety(T) and apply the right marker on each branch. But it's a separate change from introducing the marker mechanism — the ADR establishes the building blocks; container updates consume them. Each prelude container (Vec, String, Option, Result) gets its own follow-up PR.

This also means Mutex(T) and Arc(T) need no compiler-side special knowledge for thread-safety inference. They become prelude functions whose conditional safety claim lives in their own comptime if over @thread_safety(T), mirroring Rust's unsafe impl<T: Send> Sync for Mutex<T> and unsafe impl<T: Send + Sync> Send + Sync for Arc<T>. The detailed designs (clone semantics, atomic refcount machinery, poison handling) are out of scope for this ADR.

Decision

Trichotomy: ThreadSafety enum

Add to gruel-builtins/src/lib.rs:

#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum ThreadSafety {
    Unsend,
    Send,
    Sync,
}

The variant order (Unsend first, Sync last) makes the derived Ord impl give Unsend < Send < Sync, which is the ordering used by the inference rule's min over members.

Registry shape: MarkerKind::ThreadSafety

Extend MarkerKind:

pub enum MarkerKind {
    Posture(Posture),
    ThreadSafety(ThreadSafety),  // new
}

Append three rows to BUILTIN_MARKERS:

BuiltinMarker {
    name: "unsend",
    kind: MarkerKind::ThreadSafety(ThreadSafety::Unsend),
    applicable_to: ItemKinds::STRUCT_OR_ENUM,
},
BuiltinMarker {
    name: "checked_send",
    kind: MarkerKind::ThreadSafety(ThreadSafety::Send),
    applicable_to: ItemKinds::STRUCT_OR_ENUM,
},
BuiltinMarker {
    name: "checked_sync",
    kind: MarkerKind::ThreadSafety(ThreadSafety::Sync),
    applicable_to: ItemKinds::STRUCT_OR_ENUM,
},

MarkerKind is the same shape on the wire as today — the registry just gains three rows. Since Posture and ThreadSafety live in different MarkerKind variants, posture mutual exclusion (copylinear) and thread-safety mutual exclusion (at most one of unsend / checked_send / checked_sync) are checked independently. A type may carry one of each:

@mark(linear, checked_sync) struct LockedPool { ... }

MarkOutcome (crates/gruel-air/src/sema/declarations.rs) grows one optional field:

pub thread_safety_override: Option<ThreadSafety>,

process_mark_directives writes it from MarkerKind::ThreadSafety arguments. Multiple thread-safety markers on the same item are rejected with a new ConflictingThreadSafetyMarkers diagnostic.

Inference: structural minimum, with built-in facts

A new flag on every type-carrying struct (StructDef, EnumDef):

pub thread_safety: ThreadSafety,

Inference is implemented in is_thread_safety_type(ty: Type) -> ThreadSafety on the type pool, mirroring the existing is_type_linear shape:

pub fn is_thread_safety_type(&self, ty: Type) -> ThreadSafety {
    match ty.kind() {
        // Built-in negative facts:
        TypeKind::PtrConst(_) | TypeKind::PtrMut(_) => ThreadSafety::Unsend,

        // Built-in positive facts (primitives are intrinsically Sync):
        TypeKind::I8 | TypeKind::I16 | TypeKind::I32 | TypeKind::I64
        | TypeKind::U8 | TypeKind::U16 | TypeKind::U32 | TypeKind::U64
        | TypeKind::Usize | TypeKind::Bool | TypeKind::Char
        | TypeKind::Unit | TypeKind::Never | TypeKind::F32 | TypeKind::F64
            => ThreadSafety::Sync,

        // Composite types: minimum over members.
        TypeKind::Array(array_id) => {
            let (elem, _len) = self.array_def(array_id);
            self.is_thread_safety_type(elem)
        }
        TypeKind::Tuple(tuple_id) => self.tuple_def(tuple_id).iter()
            .map(|&t| self.is_thread_safety_type(t))
            .min().unwrap_or(ThreadSafety::Sync),
        TypeKind::Ref(t) | TypeKind::MutRef(t) =>
            self.is_thread_safety_type(t),

        TypeKind::Struct(struct_id) => self.struct_def(struct_id).thread_safety,
        TypeKind::Enum(enum_id) => self.enum_def(enum_id).thread_safety,

        // ... (other kinds delegate to their members)
    }
}

Note: prelude containers like Vec(T) will infer as Unsend until their prelude code is updated to apply the appropriate marker via comptime if (see Future Work). That update is independent of this ADR.

For named structs/enums, the thread_safety field on the def is populated during the existing validate_posture_consistency pass (renamed to validate_consistency to cover both axes):

  1. Inference pass. Compute the structural minimum over members. Anonymous structs/enums use the same logic on the fly.
  2. Override pass. If MarkOutcome.thread_safety_override is Some(declared):
    • declared = Unsend → write Unsend (always safe).
    • declared = Send → write Send. Permitted regardless of inferred value (downgrade Sync → Send and upgrade Unsend → Send are both legitimate; the latter is the user-checked claim).
    • declared = Sync → write Sync. Permitted regardless of inferred value (the user-checked claim).

No diagnostic on legitimate downgrades — the user has stated their intent, the compiler does what they asked.

A future ADR may add lints for redundant overrides (@mark(checked_send) on a structurally Send type contributes nothing). Not in this ADR.

@spawn(fn, arg) → JoinHandle(R)

fn worker(input: Job) -> Report { ... }

fn main() -> i32 {
    let handle: JoinHandle(Report) = @spawn(worker, Job { ... });
    let report: Report = handle.join();
    report.summary
}

Sema checks at the call site:

  1. worker resolves to a top-level function (no methods, no anonymous functions; future work).
  2. worker takes exactly one parameter.
  3. arg's type matches worker's parameter type, in the existing bidirectional sense.
  4. worker's parameter type's thread_safety is ≥ Send.
  5. worker's return type's thread_safety is ≥ Send.
  6. worker's parameter type is not Linear and not a reference type (Ref(T) / MutRef(T)) — both for the obvious reason that the spawned thread outlives the caller's scope.

Failure modes get dedicated diagnostics: SpawnArgNotSend, SpawnReturnNotSend, SpawnArgIsRef, SpawnArgIsLinear, SpawnFunctionWrongArity, SpawnFunctionNotFound.

The single-argument shape is deliberate. With no closures (ADR-0055), the call site cannot smuggle in extra captures, so multi-argument spawn would need varargs or tuple-wrapping at the call site. Tuple wrapping is the user's job here; the intrinsic surface stays small.

JoinHandle(R) built-in

A new linear built-in container, lowered through the BuiltinTypeConstructor registry from ADR-0061 (the same path Vec(T) uses):

  • Linear posture. Cannot be silently dropped; the user must join it. Mirrors Vec(T:Linear)'s must-consume discipline (ADR-0067).
  • join(self) -> R consumes the handle and blocks until the spawned function returns, yielding the result.
  • Thread-safety: unconditionally Send. The handle owns a thread-handle pointer; it does not store an R (the runtime writes R into a slot the handle owns, but R only flows back through join). R: Send is enforced at the @spawn site, so JoinHandle(R) is paired with a Send R by construction. The handle itself can be moved to another thread to join from there. Implementation: the synthetic struct definition carries @mark(checked_send) (the only built-in compiler-side use of an upgrade marker, justified by the handle being a compiler-internal type with no user-visible fields to override).
  • Drop backstop. __gruel_drop_JoinHandle aborts the program with "JoinHandle dropped without join" — defensive net for runtime cases the linearity check misses.

Comptime query intrinsic: @thread_safety(T)

One new intrinsic in gruel-intrinsics:

  • @thread_safety(T) -> ThreadSafety — returns one of ThreadSafety::Unsend, ThreadSafety::Send, or ThreadSafety::Sync. Compile-time evaluated. Mirrors @ownership(T).

Pattern:

match @thread_safety(T) {
    ThreadSafety::Unsend => @compile_error("T must be sendable"),
    ThreadSafety::Send => /* OK */,
    ThreadSafety::Sync => /* OK, even better */,
}

The ThreadSafety enum is exposed in the prelude via the same mechanism as Ownership: a synthetic enum injected by sema at declaration-resolution time.

Runtime support

Two new extern symbols in gruel-runtime:

  • __gruel_thread_spawn(thunk: *const u8, arg_buf: *mut u8, arg_size: usize, ret_size: usize) -> *mut ThreadHandle — calls pthread_create (Unix) or CreateThread (Windows). The thunk is generated per (arg type, return type) pair and unwraps the Gruel-shaped call.
  • __gruel_thread_join(handle: *mut ThreadHandle, ret_out: *mut u8) -> () — joins the thread, copies the return value out, frees the handle.

The thunk is monomorphized per @spawn instantiation (same shape Vec(T) methods are codegen'd today).

What does not change

  • Codegen for any code that doesn't use @spawn is unchanged.
  • Posture inference (ADR-0083) is unchanged.
  • The marker registry remains closed.
  • Reference types (Ref(T) / MutRef(T)) remain scope-bound (ADR-0076). Sync has no surface beyond JoinHandle today.
  • Allocator (libc malloc, ADR-0035) is already thread-safe.

Implementation Phases

Each phase ships behind --preview thread_safety, ends green, and quotes its LOC delta in the commit message.

Phase 1: Trichotomy + structural inference (no enforcement)

  • Add ThreadSafety enum to gruel-builtins/src/lib.rs with #[derive(PartialOrd, Ord)] so Unsend < Send < Sync.
  • Extend MarkerKind with ThreadSafety(ThreadSafety).
  • Append unsend, checked_send, checked_sync rows to BUILTIN_MARKERS.
  • Add PreviewFeature::ThreadSafety (thread_safety) to gruel-util/src/error.rs.
  • Add thread_safety: ThreadSafety field to StructDef and EnumDef in gruel-air.
  • Extend MarkOutcome with thread_safety_override: Option<ThreadSafety>, gated behind thread_safety.
  • Implement is_thread_safety_type(ty: Type) -> ThreadSafety in intern_pool.rs, with built-in facts: primitives → Sync, pointers → Unsend, refs/arrays/tuples inherit structurally. Prelude container types (Vec, String, Option, etc.) are not special-cased here; they will pick up correct thread-safety in their own follow-up PRs by adding a comptime if over @thread_safety(T) to their prelude definitions.
  • Implement structural inference for named structs/enums in validate_consistency (renamed from validate_posture_consistency): compute the min over members, then apply the override.
  • Anonymous struct/enum literals get the same min-of-members treatment in find_or_create_anon_struct / find_or_create_anon_enum.
  • Mutual exclusion: at most one thread-safety marker per item; conflict produces ConflictingThreadSafetyMarkers.
  • Spec tests under cases/items/thread-safety.toml: i32_is_sync, bool_is_sync, unit_is_sync, struct_of_primitives_is_sync, struct_with_ptr_is_unsend, tuple_of_primitives_is_sync, array_of_primitives_is_sync, unsend_marker_downgrades, checked_send_overrides_unsend, checked_sync_overrides_send, mutually_exclusive_thread_safety_markers, thread_safety_combined_with_posture_marker, mark_thread_safety_preview_gated. Note: vec_of_i32 / string thread-safety tests live with the respective container update PRs, not here.

Phase 2: Built-in negative facts on raw pointers

  • Verify Phase 1's is_thread_safety_type returns Unsend for TypeKind::PtrConst(_) and TypeKind::PtrMut(_).
  • Verify propagation through composite types: Tuple(i32, MutPtr(u8)) is Unsend, [MutPtr(i32); 4] is Unsend, etc.
  • Spec tests: ptr_is_unsend, mutptr_is_unsend, array_of_ptr_propagates_unsend, tuple_with_ptr_propagates_unsend, struct_with_ptr_field_is_unsend, nested_struct_with_ptr_is_unsend. (Direct queries via @thread_safety(T) arrive in Phase 3 — Phase 2 verification lives in gruel-air unit tests [thread_safety_* in intern_pool.rs] plus the *_can_be_marked_checked_send / *_can_be_marked_checked_sync spec cases that exercise the inference indirectly through marker override behavior.)

Phase 3: Comptime query @thread_safety

  • Add IntrinsicId::ThreadSafety to the gruel-intrinsics enum.
  • Append the IntrinsicDef (kind: Type, category: Comptime, runtime_fn: None, preview: Some(PreviewFeature::ThreadSafety)).
  • Inject a ThreadSafety enum into the prelude during sema's builtin-enum injection pass (alongside Ownership).
  • Sema arm in analyze_type_intrinsic reads the type's thread_safety.
  • Codegen lowers to a constant enum value at LLVM emission (compile-time constant; no runtime call) — handled by reusing the existing EnumVariant air node, which lowers as a constant.
  • Run make gen-intrinsic-docs to regenerate docs/generated/intrinsics-reference.md.
  • Spec tests: thread_safety_returns_sync_for_i32, thread_safety_returns_unsend_for_ptr (covered as thread_safety_returns_unsend_for_marked_struct since the type-position parser does not accept MutPtr(u8) as a direct argument to a type intrinsic — the same pre-existing limitation affects @ownership(MutPtr(u8))), thread_safety_returns_send_for_struct_with_checked_send, thread_safety_in_comptime_branch.

Phase 4: JoinHandle(R) built-in

  • Define JoinHandle as a prelude function (per ADR-0066/0082 pattern, supersedes the original "BuiltinTypeConstructor" framing — Vec moved out of the constructor registry to a pub fn Vec(comptime T: type) -> type body, and JoinHandle follows the same shape). Lives in prelude/join_handle.gruel, re-exported from _prelude.gruel.
  • Posture: Linear via @mark(linear) on the inner struct head.
  • Thread-safety: inner struct carries @mark(checked_send) (unconditionally Send; see Decision § JoinHandle(R) rationale).
  • Drop impl: inline fn drop(self) panics with a clear message — backstop only. Replaces the originally-named __gruel_drop_JoinHandle runtime function (Phase 5 will land the runtime side once @spawn exists).
  • join(self) -> R method registered on the inner struct. Currently panics — Phase 5 swaps the body for a __gruel_thread_join call once the runtime function exists.
  • Spec tests: join_handle_is_send, join_handle_is_linear, join_handle_must_be_consumed (placeholder until Phase 5; JoinHandle has no public constructor today, so the linearity check has no surface to fire on). The join_handle_join_returns_r case lands with Phase 5 alongside @spawn.

Side fix that surfaced while wiring this up: the existing analysis and comptime paths processed @derive(...) on anonymous structs but not @mark(...). Posture/thread-safety markers on pub fn Foo(comptime T: type) -> type { @mark(...) struct {...} } bodies were being silently dropped. Two new helpers apply_anon_struct_marks / apply_anon_enum_marks close that gap in crates/gruel-air/src/sema/declarations.rs, and the four construction sites (analysis + comptime; struct + enum) now invoke them.

Phase 5: @spawn intrinsic + runtime support

  • Add IntrinsicId::Spawn and the IntrinsicDef entry. Arguments: function reference + value; preview gated.
  • Sema: resolve the function reference, check arity, check arg type matches parameter, check arg ≥ Send, check return ≥ Send, check arg is not Linear and not Ref/MutRef. New error kinds SpawnArgNotSend, SpawnReturnNotSend, SpawnArgIsRef, SpawnArgIsLinear, SpawnFunctionWrongArity, SpawnFunctionNotFound.
  • Codegen: stub falls through to the registry's default _ => arm in translate_intrinsic, which produces a zero-init JoinHandle struct. The user-visible behavior is: the program compiles, but JoinHandle::join(self) runs the prelude's panic body ("requires --preview thread_safety with @spawn (Phase 5 of ADR-0084)"). The full per-instantiation thunk + heap marshaling is a follow-up that consumes the runtime functions already in gruel-runtime (see next checklist item). This is sufficient for the validation surface — the ADR's safety story lives in sema, and the runtime wiring is an implementation detail of the preview feature.
  • Runtime: __gruel_thread_spawn and __gruel_thread_join in crates/gruel-runtime/src/thread.rs, backed by pthread_create / pthread_join. The functions exist as unsafe extern "C" exports so the codegen follow-up can wire them up without further runtime work. Windows support deferred to Future Work.
  • Panic policy: documented in prelude/join_handle.gruel and crates/gruel-runtime/src/thread.rs. Future ADR can add a Result-typed join.
  • Spec tests: spawn_arg_must_be_send, spawn_return_must_be_send, spawn_accepts_sync_arg, spawn_rejects_ref_arg, spawn_rejects_linear_arg, spawn_rejects_wrong_arity, spawn_rejects_unknown_function, spawn_intrinsic_preview_gated, plus the join_handle_must_be_consumed linearity test (now executable, since @spawn provides a constructor). The ADR-listed spawn_basic_returns_value and spawn_thunk_handles_zero_sized_return cases land with the codegen follow-up; today's spawn_accepts_sync_arg exercises the full validation + lowering chain and asserts on the panic-exit (101) since the prelude join body is the active runtime path.

Phase 6: Spec text + corpus

  • New spec section docs/spec/src/03-types/15-thread-safety.md describing the trichotomy, structural minimum inference, the @mark(unsend) / @mark(checked_send) / @mark(checked_sync) overrides, and the built-in facts (primitives → Sync, raw pointers → Unsend). Note in the section that prelude containers default to their structural inference until updated separately. (Landed in Phase 1 alongside the corresponding tests so traceability stayed at 100%.)
  • New spec section docs/spec/src/04-expressions/17-spawn.md describing the @spawn intrinsic and JoinHandle semantics. (Renumbered from 14-spawn because slot 14 is held by 14-comptime.md4.17:* paragraph IDs are unique.)
  • Update the auto-generated reference page (docs/generated/builtins-reference.md) to list the ThreadSafety enum alongside Ownership — extended the hardcoded enum table in crates/gruel-builtins/src/lib.rs and regenerated. The 02-lexical-structure/05-builtins.md handwritten page covers the @-builtin grammar and the @allow directive only; per-intrinsic and per-enum docs come from the registry-generated page, so the new entries surface there.
  • Add a worked example examples/spawn.gruel plus an expected.toml entry. Required teaching the example test runner about the preview field so preview-gated examples compile with the right --preview flag (a small extension to gruel-test-runner/src/bin/gruel-examples-tests.rs).
  • Regenerate docs/generated/builtins-reference.md and docs/generated/intrinsics-reference.md.

Phase 7: Stabilize

  • Remove PreviewFeature::ThreadSafety from gruel-util/src/error.rs. --preview thread_safety no longer recognized.
  • Strip preview = "thread_safety" and preview_should_pass = true from every spec case still carrying them. Three preview-gating-only test cases (thread_safety_intrinsic_preview_gated, spawn_intrinsic_preview_gated, mark_thread_safety_preview_gated, unsend_marker_preview_gated) deleted since the gate they were asserting is gone.
  • make test passes on the final state, including the thread-safety spec section in the traceability check.
  • ADR status → implemented; frontmatter updated.

Consequences

Positive

  • Foundation for safe concurrency. Every primitive that follows (Mutex, Arc, channels, statics) reads the existing thread_safety field instead of reinventing the taxonomy.
  • No new compiler-side type-pool special cases for containers. The thread-safety inference engine knows about primitives and raw pointers; everything else uses the structural rule. Container-specific overrides (Vec(i32) is Sync, Mutex(T) is conditionally Sync, etc.) live in prelude code via comptime if + the new markers, mirroring Rust's unsafe impl<...> machinery rather than growing compiler magic.
  • Single trichotomy is simpler than two axes. One field per type, one min operation, one marker namespace, one @spawn check. The exotic Sync + !Send case is closed off (matching Rust convention's de facto invariant).
  • @spawn gives the markers immediate teeth. Without an enforcement site the markers would bit-rot; the minimal spawn shape produces real "this argument is not Send" errors.
  • Closed marker registry stays clean. Three rows added; the closed taxonomy holds. No new directive form, no new keyword.
  • The checked_ naming convention is self-documenting. Reading @mark(checked_send) in a struct head immediately signals "the user is making an unverifiable claim here" — code reviewers can scan for it the same way they scan for unsafe in Rust.

Negative

  • Sync surface is small today. The trichotomy is fully meaningful only once shared references / Mutex / Arc / statics exist. Until then the Sync distinction matters only for forward-looking comptime checks.
  • @spawn is initially limited to primitives and primitive-only structs. Until each prelude container's follow-up PR lands, Vec(i32), String, Option(i32), etc. infer as Unsend (their internal MutPtr poisons the structural minimum). Realistic usage — @spawn(worker, vec_of_jobs) — waits on those follow-ups. This is acceptable for a foundation ADR but means the initial end-to-end story is small.
  • Adds a runtime dependency on libpthread. Builds that don't use @spawn are unaffected (the symbol is only emitted on demand), but the test matrix expands to cover threaded execution.
  • Per-instantiation thunks for @spawn. Each @spawn(fn, arg) call produces a generated trampoline keyed on (arg type, return type). Code-size cost in programs that spawn many distinct types; typical usage spawns a small number of worker shapes.
  • JoinHandle is the first non-Vec linear built-in. Some of the linear-container plumbing from ADR-0067 was Vec-shaped; making it work for JoinHandle may surface assumptions. Bounded: the ADR-0067 design is generic in principle.
  • No closures means no captures. Workers can't reference outer state without explicit argument-passing. Acceptable for v1 (matches ADR-0055's stance) but limits ergonomics. A future closure ADR would enable thread::spawn-style captures.

Neutral

  • Codegen for non-spawn code is unchanged.
  • Posture semantics, Copy ⊥ Drop, @ownership(T), @implements(T, I): all unchanged.
  • The @mark directive grammar is unchanged. Three new argument names, same shape.

Open Questions

  1. @spawn argument shape: single, variadic, or tuple-required? The proposal is single-argument with the user wrapping in a tuple for multi-arg cases. Variadic would be more ergonomic but complicates the intrinsic signature and the thunk codegen. Could revisit if tuple-wrapping is awkward in practice.

  2. @spawn panic policy. Currently a panic in the spawned function aborts the whole process. The Rust convention is Result<R, Box<dyn Any>> from join. Aborting is simpler and doesn't pull in any error-handling design questions; switching to Result is non-breaking (a future ADR can change the join return type behind a preview gate).

  3. Lints for redundant overrides. @mark(checked_send) on a structurally Send type contributes nothing. Should it be a warning? Defer to a future lint ADR.

  4. Should ThreadSafety::Send + !Sync ever be representable for user types in a future revision? The trichotomy closes the door on Sync + !Send. The reverse case (Send + !Sync) is covered: a struct holding a MutPtr with @mark(checked_send) produces exactly that. So the trichotomy doesn't lose expressiveness in the direction users actually need.

  5. Negative facts on raw pointers: configurable in the future? Today Ptr(T) is unconditionally Unsend. A future NonAliasingPtr(T) or similar could be Send or Sync. This ADR keeps the built-in fact as-is; future negative-pointer types get added to the constructor registry with their own thread-safety rules.

Future Work

This ADR is intentionally narrow. Out of scope:

  • Prelude container thread-safety updates. Vec(T), String, Option(T), Result(T, E), and any other prelude container with an internal MutPtr infers as Unsend after this ADR lands. Each gets a one-PR follow-up that wraps its body in a comptime if over @thread_safety(T):
    pub fn Vec(comptime T: type) -> type {
        comptime if (@thread_safety(T) == ThreadSafety::Sync) {
            @mark(checked_sync)
            struct { ptr: MutPtr(T), len: usize, cap: usize, ... }
        } else if (@thread_safety(T) == ThreadSafety::Send) {
            @mark(checked_send)
            struct { ... }
        } else {
            struct { ... }   // structurally Unsend, no override
        }
    }
    
    No compiler change needed; the markers and @thread_safety intrinsic from this ADR are sufficient. The pattern is already proven to work — comptime if selecting between differently-marked struct definitions in a fn(comptime T: type) -> type body produces the right posture/marker behavior.
  • Mutex(T), Arc(T), channels. Each will be its own ADR. They live in the prelude as pub fn Mutex(comptime T: type) -> type / pub fn Arc(comptime T: type) -> type and use the same comptime if + marker pattern as the container updates above — no compiler-side type-pool arms needed. Mutex's rule: Sync if T >= Send, else Unsend. Arc's rule: Sync if T == Sync, else Unsend.
  • Stored references (Ref(T) / MutRef(T) in struct fields). Out of scope per ADR-0062. When that lands, MutRef(T) may need to drop to Send (not Sync) — the inference rule for the reference types would slot into Phase 1's propagation.
  • Detached threads. No way to opt out of joining today; every spawn returns a linear JoinHandle. A future @spawn_detached(F, arg) could exist for fire-and-forget cases.
  • Thread-local storage. No thread_local! equivalent.
  • Result-typed join. Currently a panic in the worker aborts the process. Future ADR could change join(self) -> R to join(self) -> Result(R, ThreadPanic).
  • Closures with captured environments. Would let @spawn take a true closure instead of a function reference + arg. Depends on a real closure ADR.
  • Windows thread support. @spawn runtime backs onto pthread in v1; Windows port is a follow-up.
  • Two-axis (Send + Sync) refinement. If the trichotomy ever proves too coarse — specifically if the Sync + !Send case becomes important — split ThreadSafety into two flags. The marker namespace already has unsend / checked_send / checked_sync cleanly separated; the migration would be mechanical.

References