pub Function Promises About PausingStatus: 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)
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).
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.
"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.
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).
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.
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, …).
f is allowed and nothing else may pause. A line
that does is refused as a written sync refuses it:
apply is sync(f), and fs::write can pause.apply(g, x) is sync where the lambda given for f is, and
may pause where it may. A caller in a par_iter lambda can use it with a
sync lambda and not with a pausing one.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).
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).
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.
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.
Of D2, D4 and D5:
sync(f); the ledger
already reads "from(f)".pub function could promise more than it
does, and D5’s warning where one has lost what it had; D5 compares against
the committed ledger, which --locked already reads.Of D1 and D3:
sync column is read differently depending on whether the reader is in
the same package. The ledger itself does not change.sync
today will be refused after D1 until the dependency writes sync. D2’s note
lists exactly which functions to mark.sync = \"inferred\" is a promise the body
implies” becomes “… a fact about the body, which the package itself relies
on and its consumers do not.”Built at 0.0.255: D4, sync(f) in the source.
sync(f) and sync(f, g) where sync stands, after the
result. A trait method’s is not built: it reads as a method that may pause,
the promise’s weaker side.sync = "sync(f)" in the ledger (ADR-251 D4’s
spelling of ADR-029 D3’s from(f)); the inference never writes it for a
function of the program’s own, so the entry is always the source’s promise.sync check, which allows the calls of the
named parameters and refuses every other pause as NK2202, apply is
sync(f), and time::sleep can pause. What it names is checked:
NK2210 for a name that is no parameter, a parameter that is not a
function, and one the function keeps.std’s: the call is sync, and
the lambda’s body is the caller’s own, so a lambda that pauses inside a
sync caller is refused there.async fn its body
is, since f may pause. A caller that is lowered as a plain fn drives the
call once with nikaia_std::rt::exec::settled - the checker has proved the
lambda it hands over does not pause, so the future is ready at its first poll,
and one that is not is an abort naming a compiler bug. A caller that may
pause awaits it. Before this, a sync caller of such a function would have
been lowered with an .await in a plain fn.crates/nikaia/tests/sync_of_a_lambda.rs: a sync caller with a lambda
that does not pause, run at both settings; a pausing lambda awaited from
main; the pausing lambda refused in a sync caller; a body that pauses
elsewhere refused in the promise’s words; and NK2210’s two cases.Built at 0.0.303: D1 and D3, together.
Program::contracts_as_promised: every dependency’s "inferred" entry reads
as Sync::Unpromised, which is may pause to every rule that asks
(NK2202, NK2209, a lock’s door). The program’s own inference reads the
dependency the same way, so a caller whose only pausing call is an unpromised
one may pause to its consumers. std has no "inferred" entry and is not
touched.total is sync, but
it calls lib::plus, which doesn’t promise that it never pauses, with the
help write sync after the result of plus in lib.Program::contracts, the ledger the
emitter reads and the one written beside the program, keeps "inferred", so
the call is not awaited.Sync::Unpromised is never written: it exists only in the checker’s copy.crates/nikaia/tests/project.rs: another_packages_sync_is_what_its_source_promised
(refused, runs as a plain call, accepted once written);
dependency_ledger.rs: the consumer’s inference with and without the word.Built at 0.0.304: D2, the note.
sync inference keeps a call to a code parameter that may pause apart
from every other pause (Reach::through_code), and hands back the functions
whose only pausing is such a call: nothing else they reach blocks, and
every function of the package they call holds. The inference still never
writes sync(f) - the promise is the source’s (§4).Program::could_promise names the program’s own pub functions whose entry
says "inferred" (write sync) and those of that list whose entry is still
plain (write sync(f), naming the lambdas); a parameter the function keeps
is not offered, since D4 refuses to name one (NK2210).nikaia build, run and test print it for the entry package only, and
nikaia lower for its file; the rustc wrapper lowers too and says nothing.
It is read off the program every lowering builds, so a cache hit says the
same.crates/nikaia/tests/project.rs: a_build_names_what_its_pub_functions_could_promise.Built at 0.0.305: D5, the warning.
sync inference records, per function, the first statement of its own
body that pauses and what in it does, and finds for every function that can
pause the shortest way through the package’s calls to such a statement
(sync::Noted::why). A pause that is a method call only the type checker
resolved has no statement to point at, and the warning says so without one.Program::lost_sync compares against the project’s committed
nikaia.contracts, read before this build writes it: a pub function of the
program’s own whose entry said "inferred" and which can now pause is
NK2211, a warning, at that statement and with the calls that lead there.
The ledger the build then writes is the acknowledgement.crates/nikaia/tests/project.rs: a_build_warns_when_a_pub_function_loses_its_sync.