ADR-027: sync Is Earned, Not Entered

Status: Superseded by ADR-288 — its D1-D6 are D1-D6 there Related: ADR-020 (the ledger the inference writes to), ADR-010 D1 (the same polarity question, answered the same way), ADR-008 D6 (@borrowed: an assertion you write where you want a property held), ADR-005 D6 and D8, ADR-029 (answers §7’s second item)


1. Context

Part II 12.1 gives sync one rule — a sync function may only call sync functions — and crates/nikaia/src/contracts/sync.rs has checked it since ADR-020 gave it a ledger to ask.

The rule is right. The default was backwards, and it took building the check to see it.

Every construct in Chapter 12 that makes concurrency safe does it by demanding a sync lambda:

construct why it demands sync
access, access_all (12.2) you must not pause while holding locked data
par_iter (12.6) the body runs on a CPU core, not an event loop
task::scope where its children run in parallel (12.7) the scope can only wait out tasks that finish on their own
the panic hook (Part I, 7.2) mid-panic there is nothing to pause on

So sync is not a niche annotation for hot loops. It is the gate on every safe concurrency primitive the language has. And it was opt-in.

1.1 What opt-in did

A function got sync in the ledger if and only if someone typed the word. So:

fn bonus(score: i32) -> i32 { score * 2 + 1 }

fn tally(p: &Player) -> i32 sync {
    return p.score + bonus(p.score)     // error[NK2202] before this ADR
}

bonus adds two integers. It cannot pause. The compiler had read its body, had an entry for it in the ledger, and still recorded “not sync” — because the ledger was recording what the declaration said rather than what was true.

The consequence compounds. To put anything inside access, you annotated it; to annotate it, you annotated everything it called; and anything you did not own you could not annotate at all. The restrictive side of the language was the unusable one, and the reflex it trained is the wrong one: put less inside the lock, not because the work should be outside it, but because the annotation chain was too long.

This is not a hypothetical. The check’s first run reported two examples for exactly this: a sync method building a value through a constructor that never said sync either. The constructor builds a struct literal and calls nothing — it could never pause. What the check found was a missing annotation, and a test froze that confusion in place with the violation as the expected outcome.

1.2 Why the compiler could always have known

sync-ness is a property of the call graph, and the check already walks it. check starts at a sync function, resolves each call against this unit’s ledger or a library’s, and reports what can pause. Turning that into an answer for every function is the same walk without the if !is_sync { return } at the top.

Nothing new has to be computed. The ledger (ADR-020) already holds what a call promises across the std boundary, which is the part a compiler cannot work out for itself.


2. The polarity problem

One thing does have to be decided, and it is the substance of this ADR: an analysis that claims a property must be careful in the opposite direction from one that checks a claim.

The check is conservative in the permissive direction, and says so in its own header: with no receiver types, a.method() cannot be looked up, so it is not an error. That is right for a check. It never rejects a correct program, and an unchecked promise catches nothing at all.

Reuse that polarity for inference and it becomes a defect. “I cannot see what this calls, therefore sync” writes a promise into a file that is shipped (13.5), read by a consumer’s compiler, and used to admit a body into access. A wrong sync there is a pausing body inside somebody else’s lock — a deadlock on one thread, a stalled core on several, and no diagnostic anywhere, because every compiler involved was told the thing was pure.

The ledger has already answered this question once, for provenance (ADR-010 D1):

Where provenance cannot be established the answer is Untrusted, never Trusted — an analysis that fails open is a vulnerability generator.

The same sentence with the nouns changed is this decision.


3. Decisions

D1 — sync is inferred from the body, for every function.

A function is sync when every call it makes resolves, and every callee is sync. The inference runs after the ledger’s entries exist, over the call graph of the compilation unit, resolving into a library’s ledger exactly as the check does.

The fixpoint is a greatest one: start from “every candidate is sync” and take the claim away from anything that reaches a function without it, until nothing changes. That is not an implementation detail — it is what makes mutual recursion between two pure functions come out sync, which is the true answer and the one a least fixpoint gets wrong.

D2 — The inference is conservative in the restrictive direction.

A call the compiler cannot resolve costs the function its claim. That covers a method call (no receiver type yet — ADR-024), a name no ledger knows, spawn (its body runs later and elsewhere, so the function around it is not a pure CPU task), and a dsl (whose action blocks are arbitrary Nikaia).

The check keeps the permissive polarity it already had. The two live in one file and share one resolution rule (reached), and they disagree only about what to do with a call that rule cannot name: the check shrugs, the inference refuses. Having them disagree about what a call resolves to would be a bug; having them disagree about what an unresolvable one means is the design.

And the check is conservative in the same direction, which it was not. A source that asserts sync and calls something no ledger knows kept its assertion, because D4 says an assertion is never overwritten by the inference and the check returned nothing for an unresolvable call. So a ledger that ships (ADR-020) could carry sync = true for a function that may pause, and a consumer’s par_iter body would believe it.

The permissiveness was bought with a diagnostic: NK2202 wants a caret on the call. It was buying nothing — Part III C.2 reports this checker at statement granularity already, which is a position. A method call stays permissive here and is a different case: the type checker resolves one (ADR-028) and the inference merges that answer in per function, so the claim is still taken away where it must be.

D3 — The ledger records which of the two it is.

sync = true where the source asserted it, sync = "inferred" where the body implies it. Absent still means not sync, so nothing about how the file is read changes.

A caller must not distinguish them, and does not: both mean “cannot pause”, and everything that asks the caller’s question asks it through one predicate.

A diff must distinguish them, which is why a bool is not enough. Withdrawing an asserted sync is a decision somebody made and has to have meant. Losing an inferred one is a consequence of an edit somewhere else, usually further down the call graph. 13.5 asks this file’s diffs to narrate causes; two different events that print the same line cannot be narrated apart.

D4 — An assertion is never overwritten by the inference.

Where the source says sync, the ledger records Asserted and the inference leaves it alone — including where the inference could not have derived it. That is deliberate: the keyword’s remaining job is to hold a property the compiler cannot yet prove, and NK2202 reports what it can see contradicting it. An inference that quietly downgraded an assertion would remove the only tool a person has for the cases D2 refuses.

So the keyword changes meaning without changing syntax. It was a mode you entered; it is an assertion you make — the same move @borrowed makes in Part I 6.6, checked the same way, and with the same effect on the generated program, which is none.

D5 — The ledger header names the change.

inference goes from stage0-signatures to stage0-signatures+sync-bodies, because a ledger whose sync was read off a declaration is not one whose sync was read off a body. 13.5 built this header for exactly this event; a regenerated ledger shows the header moving, and the narration says the toolchain did it rather than the code.

D6 — --locked names the entry a changed line belongs to.

Not strictly part of the inference, and caused by it. sync is now the commonest line in a ledger, and the --locked diff reported bare lines with no owner:

What the build inferred and the ledger does not have:
    sync = true
    sync = true
    sync = true

That says something moved and leaves the reader to find out what. It now says whose contract moved, which is the least a diff meant to narrate a cause can do.


4. What it buys, measured

examples/1brc.nika carries seven sync declarations. Stripping all seven and rebuilding:

function recovered why
Stats::new, Stats::add, Stats::merge, Stats::mean ✅ sync = "inferred" arithmetic on fields; no calls to resolve
Summary::new, Summary::record, Summary::merge ❌ reach HashMap::new() and .entry(…) — a path and a method no ledger names

Four of seven, and the boundary is exactly D2’s: what the resolver can name. The remaining three are not a limit of this decision but of ADR-024’s open work — a method call becomes resolvable the day its receiver has a signature written down, and neither analysis here has to change for that to help. The number is expected to rise on its own.

The stripped file compiles clean either way, which is the other half of the result: the check did not merely stop reporting, it had nothing to report.


5. Considered and rejected

Infer, and treat an unresolvable call as harmless. More functions get the promise, and some of them cannot keep it. Rejected under §2: the entry ships.

Infer, and report a warning where the inference could not decide. Every method call in every program, for a question most callers never ask. It would train people to ignore the warning, and the thing to fix is not in their code.

Drop the keyword now that bodies are read. It is the only override for D2’s refusals, and the only way to say “hold this function to being pure” about a body that would otherwise lose the property silently on the next edit. Removing it would make the compiler’s inability to see a receiver type into a permanent ceiling instead of a temporary one.

Record one sync = true for both origins. Simpler file, and it makes an assertion being withdrawn indistinguishable from an inference lapsing. D3.

Infer throws the same way. Tempting, and out of scope: throws is part of a function’s published shape (a caller writes catch, or declares it in turn), so deriving it silently changes what callers must write rather than what they may assume. sync is a permission, and throws is an obligation; they do not take the same treatment. See also the semver question in §7.


6. Consequences


7. Open

A pub function that loses an inferred sync is a breaking change for its consumers, and nothing says so yet. The machinery is in place — --locked compares byte for byte and D6 names the entry — but nothing classifies the change. The same question applies to a pub function gaining throws, which today can be triggered by an implementation detail (Part I 6.4: a resource whose cleanup can fail). Both want the same answer: a ledger diff on a pub entry is a semver event and should be reported as one, by name.

Effect polymorphism was the larger version of this problem: a higher-order function had to commit to one answer for all of its callers, so no map was sync, whatever the lambda did. Answered by ADR-029, and in the shape predicted here — the ledger already said “depends on a parameter” for borrows, and the same shape serves a different question.

This site is open source. Improve this page.