ADR-244: What a pub Function Promises About Pausing

Status: Superseded by ADR-288 — its D1-D5 are D28-D32 there Date: September 27, 2026 Answers: ADR-027 §7’s first paragraph (a pub function that loses an inferred sync is a breaking change for its consumers, and nothing says so yet) Related: ADR-029 D3 and D4 (sync = "from(f)", and why it is for an immediate lambda only), ADR-100 (a consumer reads a dependency’s ledger), Part I 5.4 C (a function type in a signature), Part III 13.5 (the ledger’s sync column), Part II 12.1–12.3 (where a call must not pause)


1. Context

sync has three written states in a ledger: true (the source wrote sync), "inferred" (the body cannot pause, and nobody promised it) and "from(f)". A consumer in another package reads "inferred" exactly as it reads true. So an observation about today’s body is published as if it were a promise, and a change inside the body — a log line written to a file — withdraws it.

Whether that breaks a caller depends on where the caller stands, not on the change alone. Measured at 0.0.228, with a callee that gains one pausing call:

the caller calls it from result
an ordinary function compiles; the caller itself now pauses
a function it declared sync NK2202
a lock’s update or access NK2202
a par_iter lambda NK2209
a sync trait method’s body NK1129

Nothing breaks silently: every caller that breaks is refused at its own line, in Nikaia’s words. What is wrong is who learns it and when — the consumer, at upgrade, instead of the author, at the change. And because the property is inferred through the whole package, one line deep inside can withdraw it from many pub functions at once; each of their consumers inherits the question.

std already avoids all of this, because its ledger is written by hand. Of its 175 entries, 140 say sync = true, 13 sync = "from(f)", and the remaining 22 say nothing and are read as pausing. No std entry is "inferred".

throws already has the shape D1 proposes for sync: the word is written on the signature (a failing call in a function without it is NK2605), and only the set of errors is inferred, with its growth reported at every catch (NK2402).

2. Decisions

Each Dn is one decision and is cited on its own. D1 is the rule for consumers. D2, D4 and D5 are what an author is given: the word to promise with, a note where a promise could be made, and a warning where a property is lost. D3 keeps D1 free at run time.

D1 — A consumer reads "inferred" as absent.

Across a package boundary, a pub function is sync only where its source says sync or sync(f) (D4). "inferred" stays in the ledger, because it is true and the author’s own tools read it, but a call from another package treats it as may pause. Inside the package nothing changes: the body is visible, and inference is what it is for.

So the published promise is only what was written, the widest promise — may pause — is the default, and no change to a body can widen what was promised. Removing a written sync is a change to the signature the author can see in the diff; adding one is always compatible.

The polarity is the opposite of throws, and on purpose: pausing harms only the callers that sit in one of the five contexts above; failing is a question for every caller.

D2 — The author is told which pub functions could promise sync and do not.

The compiler knows which pub functions never pause: their ledger entry says sync = "inferred". A build names them once, as a note, with the word that promises it:

note: 3 pub functions never pause and do not promise it: `parse`, `total`, `weight`
  help: write `sync` after the result, and callers in other packages can rely on it

A function whose only pausing is its lambda’s is named with sync(f) (D4) as the word to write. The note turns an observation that a later line can withdraw into a promise the author chooses to make, after which a body that breaks it is refused in the author’s own build (NK2202; measured: a pub fn … sync with a pausing call is refused there, not at the consumer).

D3 — The check reads the promise; the lowering reads the body.

A call is checked against D1’s promise and lowered by what the callee’s body actually is. The two must be kept apart: a caller that emitted .await because the promise said may pause, on a callee lowered as a plain function, would not compile. Nothing new has to be derived for the lowering: the ledger still says "inferred", and a consumer reads a dependency’s ledger and never derives it (ADR-100). D1 changes what the checker concludes from that entry, not what the entry says. The only cost D1 adds is therefore in the caller’s own contract: a caller whose only pausing call is an unpromised sync callee is itself may pause to its consumers — which D2’s note is there to repair at the source.

D4 — sync(f) promises that a function pauses only where its lambda does.

pub fn apply(f: fn(i64) -> i64, x: i64) -> i64 sync(f) {
    return f(x)
}

sync(f) stands where sync stands, after the result, and names parameters of the function’s own list; several are written sync(f, g). It is the source form of the ledger’s sync = "from(f)" (ADR-029 D3), which std’s hand-written ledger already uses for 13 entries (map, filter, sort_by_key, the lock doors update and access, …).

The stricter contract stays available and means something else: f: fn(i64) -> i64 sync with a plain sync on the function promises that it never pauses and refuses every pausing lambda at the call (NK2206), and its body calls f as a sync call (0.0.244).

D5 — The author is warned when a pub function loses the sync it had.

Where a pub entry said sync = "inferred" in the committed ledger and the build finds that it can now pause, the build warns, at the change, with the line that made it pause and the calls that lead there:

warning: `parse` could not pause before, and now it can
  --> src/config.nika:40:9
   40 |         fs::write(log, fs::Root::Anywhere, line; append: true)
     = `parse` calls `read_section` (src/config.nika:12), which calls this
     help: if `parse` should not pause, move this out of it; if it may, commit
           the ledger, and this is not said again

Under D1 no consumer is refused by this, since none relied on the inference. What the author loses is the promise D2 would have offered, and the property the package’s own callers may be counting on. The committed ledger is the acknowledgement, as it is for an error newly reaching a catch (NK2402): once the new entry is committed the warning is not repeated. A written sync needs no warning, because a body that breaks it is already refused (NK2202).

3. Not covered

keeps. By the same reasoning, an unmarked pub parameter would be read as kept across the boundary unless the source writes ref, so a consumer could never rely on an argument surviving the call. That would put a .clone() at most calls into another package. The cost is large enough to want its own record and a measurement over examples/ first; it is not proposed here.

The registry’s semver rule. Whether removing a written sync requires a major version is the registry’s question (ADR-103), and it is simpler after D1: the only sync change that can break a consumer is one the author made to a signature.

4. Considered and rejected

For D4: publish "from(f)" when it is inferred, and write nothing. A body whose only pausing calls are to its lambda would be "from(f)" across the boundary with no word in the source. Cheapest, and the same fault D1 is about in a smaller place: a log line added to apply withdraws it, and the consumer’s par_iter call is refused at upgrade. Worse than for a plain function, because there would be no word the author could write to have their own build hold it.

For D4: leave it unwritable. A pub higher-order function would promise either nothing or, with a sync parameter type, that every lambda passed to it is sync. The first is too weak for a par_iter caller, the second refuses every pausing lambda even where pausing is harmless.

Classify ledger diffs as breaking or not. This was the first answer to ADR-027 §7: compare the old and new ledger and call a lost sync a breaking change. It cannot be right in general, because the table in §1 shows the same change breaking some callers and not others, and the author cannot see the callers. It also leaves the observation published as a promise.

Lower an unpromised callee as pausing, too. Simpler than D3 — one answer per function — but every call into another package whose author forgot a sync would build a future. D2 exists so that authors add the word; until they do, the program should not pay for it at run time.

5. Consequences

Of D2, D4 and D5:

Of D1 and D3:

6. What is built

Built at 0.0.255: D4, sync(f) in the source.

Built at 0.0.303: D1 and D3, together.

Built at 0.0.304: D2, the note.

Built at 0.0.305: D5, the warning.

This site is open source. Improve this page.