Skip to content

The obligation the caller owns - #970

Merged
onatozmenn merged 2 commits into
mainfrom
the-reason-a-caller-owns
Aug 10, 2026
Merged

The obligation the caller owns#970
onatozmenn merged 2 commits into
mainfrom
the-reason-a-caller-owns

Conversation

@onatozmenn

Copy link
Copy Markdown
Collaborator

Every ensures clause that came back Guarded gave the same reason: "nothing tries to prove this one ahead of time, so it is checked on every call". Nine obligations across examples/ and std/ said it. Seven of them are about an effect, and those are not waiting on anything.

A function is checked once. The with block that decides what Counter.value() or unchanged(Ledger) means belongs to whoever calls it, and a different caller may install a different handler, so no pass on this side settles one of those however hard it tries. "Nobody tried" reads as a job somebody could finish; it invites a reader to wait for a release that cannot come, which is the same failure the reason field was added to fix, one level in.

Those seven now carry Reason::TheCallerInstallsTheHandler — the one reason here whose answer to "what would make this Proven" is nothing, and the doc comment on Reason now says so.

What the split says

before after
nothing tries to prove this 9 2
the caller installs the handler - 7

The two left are transfer's result.from == from and result.amount == amount. Those really are obligations a checker could one day discharge, so the split turns one number into the one that says where the remaining work is.

How it decides

Asked of the resolver, not of the text: a clause reaches an effect if it is unchanged(E), or if any name in it resolves to an effect operation. An effect called unchanged or a local called Ledger cannot fool it.

children — "every expression one step inside this one" — moves from the interpreter to deed-ast. Which expressions are inside another is a fact about the tree, and this is its second reader; the interpreter had written it out twice before it was factored out once, and both copies had stopped in the same place.

Held by

  • obligations.rs: the split asked of a two-function program, both directions, plus unchanged on its own so that removing the arm recognising it fails with a name rather than with a count. Neither reason may be given by anything that is not an ensures clause.
  • reasons.rs: the corpus counts, pinned alongside the table in design/02-syntax.md.

Three break-verifications, each failing by name: dropping the unchanged arm, dropping the operation arm, and answering yes to everything.

Not in this PR

Proving the remaining two. result.from == from needs the checker to know that the returned record literal put the parameter in that field, which is a mechanism rather than a message, and it is what a next move here would be measured against.

Every `ensures` clause that came back Guarded said the same thing: "nothing
tries to prove this one ahead of time, so it is checked on every call". Counted
across `examples/` and `std/`, nine obligations said it, and seven of them are
about an effect.

Those seven are not waiting on anything. A function is checked once, and the
`with` block that decides what `Counter.value()` or `unchanged(Ledger)` means
belongs to whoever calls it; a different caller may install a different handler.
No pass on this side settles one of those however hard it tries. Saying "nobody
tried" invites a reader to wait for a release that cannot come, which is the
same failure the reason field was added to fix, one level in.

They now carry `Reason::TheCallerInstallsTheHandler`, the one reason here whose
answer to "what would make this Proven" is nothing. The two left saying
"nothing tries" are `transfer`'s `result.from == from` and `result.amount ==
amount`, which really are obligations a checker could one day discharge, so the
split turns a single number into the one that says where the remaining work is.

Asked of the resolver rather than of the text, so an effect called `unchanged`
or a local called `Ledger` cannot fool it.

`children`, "every expression one step inside this one", moves from the
interpreter to `deed-ast`. Which expressions are inside another is a fact about
the tree, and this is the second reader; the interpreter had already written it
out twice before that and both copies stopped in the same place.

Three break-verifications, each failing by name: dropping the `unchanged` arm,
dropping the operation arm, and answering yes to everything.

`design/02-syntax.md` reports both rows, and `reasons.rs` pins them.
`mutants on the diff` kept the branch asking whether a bare identifier resolves
to an effect operation, and it was right to: no program can reach it. An
operation is declared as a member of its effect, so nothing puts one in a scope
a bare name could find, and every mention in an expression is
`Effect.operation`, whether it is being called or handed on as a value.

The branch is gone and the assumption behind removing it is now measured rather
than asserted. `an_effect_operation_is_only_ever_named_through_its_effect`
walks every expression in `examples/` and `std/` and requires that no bare name
resolves to one, and that some field name does, so it is not passing on an
empty set. Asked of the tree rather than of `Resolutions::names`, because an
effect's own `fn note(..)` and the handler's are both bare and both are
declarations.
@onatozmenn
onatozmenn merged commit 4a22ad9 into main Aug 10, 2026
10 checks passed
@onatozmenn
onatozmenn deleted the the-reason-a-caller-owns branch August 10, 2026 18:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant