The effect system can now discharge an effect, not only track one - #4
Merged
Conversation
Effects could be declared, annotated, inferred, and enforced, but never
*eliminated*: `/ audit` propagated outward forever and the only way to satisfy
it was to keep declaring it. `effect` blocks named operations that nothing
read — an `effect` declaration was decoration.
Three parts, because none is useful alone.
INTRODUCTION. `Audit.record(x)` performs the operation: it is checked against
the signature in the `effect` block and puts `audit` in the calling function's
effect set. It previously typechecked as an unknown method returning a fresh
variable, accepted any arguments at all, and then died at run time with
`unknown function \`record\`` — the same typechecks-then-does-not-evaluate shape
as five earlier bugs this week.
ELIMINATION. `handle { body } with Audit { record(e) => … }` removes the effect
from the block it wraps, so a function can be pure despite calling something
effectful. The subtraction is per *block*, not per function: the calls inside a
handled block are collected into their own bucket, resolved, and discharged
separately, so an unhandled call sitting beside a handled one still reports.
Deleting the effect from the whole function would have been simpler and unsound.
What the arm itself does is attributed honestly — handling `audit` by writing a
file makes the handling function `/ fs`. A handler exchanges one effect for the
effects of handling it, and says so.
DECLARATION. An effect annotation naming nothing is now an error. `/ nte` used
to be accepted as a *different effect* from `/ net` — enforced consistently and
matching nothing, so a typo invented an effect instead of failing. Built-in
kinds still need no declaration; the rule is about names that mean nothing.
Handlers do not resume: an operation dispatches to its arm and returns like an
ordinary call, which is what a tree-walking evaluator can do without capturing
continuations. `resume` is the natural next step and is not here. Handlers are
found dynamically (innermost wins) and evaluated lexically (the arm sees the
scope the handler was written in); both are tested, as is the stack discipline
that stops a handler outliving its block.
`effects-showcase` demonstrates all three and drops two claims that are no
longer true. It gains `effect Db {}`, the one migration the stricter rule
required — found by running the checker over every `.mg` in the repository
rather than by guessing at the blast radius.
Counts move with the suite: prototype 1,094 → 1,106, total 2,802 → 2,814.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`Audit.recrod(x)` typechecked clean and died at run time with
`unknown function`. The effect analysis attributes an effect from the
*receiver* alone, so the misspelling was counted as genuinely performing
`audit`, satisfied the annotation, and passed every check — and then there was
nothing to dispatch to.
This is the exact bug class the feature it sits inside was built to eliminate,
written into the first version of that feature. Fixing a category of mistake
confers no immunity to committing another instance of it; the only thing that
caught this one was running a probe with a typo in it, which is also how the
other twelve were found.
An operation call on a declared effect is now checked against the effect's
declarations, and the diagnostic lists what the effect actually declares:
error: effect `Audit` declares no operation `recrod` (it declares: record)
Counts move with the suite: prototype 1,106 → 1,107, total 2,814 → 2,815.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Contributor
Author
|
Found and fixed a bug in this PR before merging it: The effect analysis attributes an effect from the receiver alone, so the misspelling was counted as genuinely performing Now checked against the declarations, with the diagnostic naming what does exist: Counts: prototype 1,106 → 1,107, total 2,814 → 2,815. Re-verified: examples green, |
This was referenced Aug 12, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes the last substantive item on
HANDOFF.md's "found and left alone" list: "Effects can be declared, annotated, inferred, and enforced, but never discharged — the effect system has no elimination rule."Design chosen deliberately: non-resumable handlers, and custom effects must be declared.
Three parts, because none is useful alone
Introduction.
Audit.record(x)performs the operation — checked against the signature in theeffectblock, and it putsauditin the calling function's effect set.Before this it typechecked as an unknown method returning a fresh variable, accepted any arguments, and died at run time with
unknown function `record`. That is the same typechecks-then-does-not-evaluate shape as five earlier bugs this week. Aneffectblock declared operations nothing ever read.Elimination.
handle { body } with Audit { record(e) => … }removes the effect from the block it wraps:--checkreportsf transcribe: { audit }andf summarize_audit: pure.The subtraction is per block, not per function. Calls inside a handled block go into their own bucket, are resolved, and are discharged separately — so an unhandled call sitting beside a handled one still reports. Deleting the effect from the whole function would have been simpler and unsound; there's a test for exactly that.
What the arm itself does is attributed honestly: handling
auditby writing a file makes the handling function/ fs. A handler exchanges one effect for the effects of handling it, and says so.Declaration. An effect annotation naming nothing is now an error.
/ nteused to be accepted as a different effect from/ net— enforced perfectly consistently and matching nothing, so a typo invented an effect instead of failing.What this deliberately is not
Handlers do not resume. An operation dispatches to its arm and returns like an ordinary call, which is what a tree-walking evaluator can do without capturing continuations.
resume— and with it generators, backtracking, and async from one mechanism — is the natural next step and is not here.Handlers are found dynamically (innermost wins) and evaluated lexically (the arm sees the scope the handler was written in). Both are tested, as is the stack discipline that stops a handler outliving its block.
Migration
Surveyed by running the checker over every
.mgin the repository rather than guessing at the blast radius. Exactly one file needed a change:effects-showcasegainseffect Db {}. It also now demonstrates all three parts, and drops two header claims that are no longer true — including "Not shown, because it does not exist yet: effect handlers."check-examples.shcaught the example's changed output and refused to bless it silently, which is what that pin is for; the new line is thehandledemonstration and the expected answer was updated deliberately.Verification
cargo test --release: 1,106, 0 failures. 12 new tests — introduction, elimination, the per-block soundness property, handler-effect attribution, unknown-effect rejection, plus runtime dispatch, innermost-wins, lexical scope, and stack discipline.scripts/check-examples.shgreen — all 12 typecheck, run, and print their recorded answers.scripts/test-all.sh --check-docsgreen; 45 documented counts match the run.Counts move with the suite: prototype 1,094 → 1,106, total 2,802 → 2,814.
🤖 Generated with Claude Code