ADR-109: A Trait Method May Pause Unless It Says sync — and the Language Below Is Rust 1.75 or Newer

Status: Superseded by ADR-288 — its D1-D4 are D24-D27 there Superseded in part by: ADR-219 — the floor is 1.88, measured; D4’s mechanism stays Date: September 16, 2026 Target Version: Nikaia 0.0.8 Related: ADR-102 D2 (the same reading for a function type), ADR-078 D4 (a trait’s method in the ledger), ADR-080 D1 (NK1129), ADR-106 (a bound across a package, and the trait table), ADR-027 (sync is an assertion; NK2202), ADR-045 (whether a value may cross a thread is the same at both settings), ADR-001 D1 (the channel is stable, and one file names it), Part I 4.7, Part III 13.2

1. Context

The README’s first claim is that functions have no colour. A trait’s methods had one: every method a trait declares was sync, because a declaration has no body to read a pause from, and because the language below had no way to write async fn inside a trait. So no trait could describe I/O — not a Handler, not a Repository, not a Source backed by a file — and a language that removed the sync/async split from functions kept it, inverted, in its traits. http cannot be hosted on that.

The second reason stopped being true with Rust 1.75, which stabilised async fn in traits and return-position impl Trait in traits. The first was never a reason: a declaration without a body is exactly what a function type is, and ADR-102 D2 already decided how one reads — without sync it may pause, without throws it cannot fail, and a body that does less fits a type that allows more. A trait method is that signature with a name in front.

2. Decisions

D1 — A trait method reads like a function type: without sync it may pause, without throws it cannot fail.

trait Source {
    fn load(&self) -> String throws     // may pause, may fail
    fn name(&self) -> String sync       // never pauses, cannot fail
}

Nothing new is written: sync and throws stand where a declaration already puts them. The ledger’s trait entry (ADR-106) records the words as written, and sync::infer reads a call through a bound as one that may pause unless the declaration says sync — so a body that calls s.load() under [S: Source] pauses where load may, and is sync where every call in it is.

D2 — An implementation is checked against the declaration the way a body is checked against its own word.

A body that pauses under a declaration that says sync is NK1129, and the message is the comparison: Disk::load pauses, and Source declares load as sync. A body that fails under a declaration without throws is refused the same way, as NK1140. Both are ADR-027’s NK2202 asked of somebody else’s signature, which is what ADR-102 D2 does for a lambda.

The other direction fits, as it does for a lambda: a body that never pauses under a declaration that may, or one that cannot fail under throws, is correct and says nothing.

D3 — The emitter writes the return-position form, and Send on the future follows the executor.

In the trait, a method that may pause is lowered as fn load(&self) -> impl Future<Output = String>; the impl writes async fn, which satisfies that declaration. The sugar async fn in the trait is not written, because the async_fn_in_trait lint warns on a public trait whose future carries no Send bound, and a warning about the generated file is a defect in this project.

The bound is + Send where user_parallelism = yes and absent at no: at yes a task crosses threads and the spawn needs a Send future; at no nothing crosses, the counts are plain (ADR-037 D7) and a Send demand would refuse them. This is a requirement of the executor the program is built for, written by the emitter, and not a claim about a type: the answer ADR-045 gives about whether a value may cross a thread stays the same at both settings, and no switch reaches it.

D4 — The language below is Rust 1.75 or newer, and the number is written where Cargo reads it.

Every generated Cargo.toml carries rust-version = "1.75", from one constant in the emitter. Before the compiler hands a build to cargo it compares rustc --version to that constant and refuses in its own words where the toolchain is older — Cargo’s package requires rustc 1.75 or newer is a message about a generated file, which Part III C.1 forbids.

The floor is a different fact from the channel. rust-toolchain.toml names the channel and stays the only place that does (ADR-001 D1); the floor is a version the emitted code needs, and it lives in the emitter. It rises only by a record — a later feature of the language below that some lowering needs — and never silently.

The compiler’s own floor is not claimed in Cargo, because it has not been measured: 1.75 cannot build the compiler, whose lockfile already carries edition-2024 crates and so needs Cargo 1.85 or newer, and nothing between that and the current stable has been tried. An operator builds the compiler with the stable that rust-toolchain.toml names, which is newer than both.

3. Consequences

4. What this does not decide

5. What is built

All four decisions. A trait method carries the word it was written with, an implementation is compared against it (NK1129 and NK1140), the declaration is lowered -> impl Future<Output = …> with + Send at user_parallelism = yes, and every generated Cargo.toml says rust-version = "1.75" with the comparison made before Cargo is handed anything.

What NK1129 became. It used to refuse every pausing implementation, because sync was asserted for every trait method (ADR-078 D4) — a plain fn was the only thing the emitter could write in a trait. D3 takes that cause away, so what is left is a comparison: Disk::load pauses and Source declares load as sync. The other direction fits and says nothing.

Three things the record had not said, each found by a test:

What moved in the corpus. Two tests in crates/nikaia/tests/package_graph.rs wrote a trait without sync and meant cannot pause; the one that holds the refusal says the word now, and the one that holds the program is a program either way. crates/nikaia/tests/traits.rs carries the rest — twenty tests, including docs/language-review.md §1.3’s probe compiled and run.

What is not built: nothing of this record. Send on the future is the emitter’s, written per setting, and is not a claim about a type — which stays contracts::send’s and the same at both settings (ADR-045, ADR-005 Group B).

This site is open source. Improve this page.