Skip to content

Bound vera/addEffect propagation at handlers (#725) - #1259

Merged
aallan merged 2 commits into
aallan:mainfrom
chethanuk:fix/issue-725-lsp-handler-aware-propagation
Aug 13, 2026
Merged

Bound vera/addEffect propagation at handlers (#725)#1259
aallan merged 2 commits into
aallan:mainfrom
chethanuk:fix/issue-725-lsp-handler-aware-propagation

Conversation

@chethanuk

@chethanuk chethanuk commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Fixes #725

Summary

  • vera/addEffect closed its transitive-caller set over the raw call graph, so a caller that wrapped its call in handle[E] still got E appended to its own effects(...) row. That row is one the program doesn't need, and it dragged the caller's own callers in behind it.
  • A call site inside a handle[E] body now contributes no edge. transitive_callers takes an optional effect argument; with None it is bit-for-bit the old handler-unaware closure, which the existing goldens still pin.
  • Containment is structural (prune the handled sub-tree) rather than the span arithmetic the issue sketches. Same answers where both apply, no special case for a node carrying no span, and it reuses the existing walk_nodes walker.
  • Handler identity is the full effect instance, type arguments included.

Why full-instance identity, not base names

My first cut compared base names, on the reasoning that this is already the identity effect_row_rewrite uses. That was wrong: the two halves ask different questions. effect_row_rewrite asks whether a row would be a duplicate, where base names suit. The closure bound asks whether the effect is discharged, and the checker answers that with full EffectInstance equality, so handle[State<Nat>] does not discharge State<Int>.

Base names therefore prune an edge the program needs, the caller keeps effects(pure), and the candidate dies on E125. On a fixture whose only call site sits inside handle[State<Nat>] while propagating State<Int>: handler-unaware gave applied=True with 0 diagnostics, base-name gave applied=False with 1. A working edit became a refused one.

Same trap one level down. ast.format_type_expr renders a RefinementType as its base type, so handle[Exn<{ @Int | @Int.0 >= 0 }>] and Exn<Int> produced identical keys, spelled lossily rather than unspellably. That handler passes vera check, and the collapsed key reproduced E125. Any argument containing a refinement now yields an unmatchable key.

Two shapes that deliberately still propagate

  • A caller that also reaches the callee on an unhandled path. The filter drops edges, not functions, so one surviving edge keeps the caller in. Bounding at the function when any path is handled is a smaller diff but unsound.
  • A call in a handler clause. A clause body runs outside its own handler, so only body is pruned.

Test plan

  • Tests written first and RED for the right reason. 108 pass now.
  • Ten mutations, none surviving: no bound at all, pruning clause bodies too, base-name identity, refinement guard removed, and the qualified arm dropping its module.
  • A positive control, because the mismatch test alone proved nothing. It shows handle[State<Nat>] doesn't prune State<Int>, but nothing established that handler prunes anything, so an implementation returning an unmatchable key for every parameterised handler would satisfy it. The control pins that a matching instance does prune.
  • One existing test had to change. Under full-instance identity, test_handler_clause_and_foreign_handler_do_not_bound stopped detecting the wrong prune boundary: its discriminating power had been resting on the base-name bug. Re-pointed at the instance its fixture actually handles.
  • Every fixture run through vera verify before being embedded, so the assertions rest on verified premises rather than strings that merely parse.
  • mypy vera/, ruff check ., check_limitations_sync.py, check_doc_counts.py, check_site_assets.py green. Full pytest tests/ and pre-commit run --all-files green.

The KNOWN_ISSUES.md and LSP_SERVER.md limitation rows and the ROADMAP Tier 3 entry are retired.

On the release merge

You flagged that #1232 and #1238 rewrote this machinery, so it wouldn't survive the merge. I ran the rebase rather than guess. git log --full-history origin/main..origin/release/v0.1.10 -- vera/lsp/workflows.py is empty and the file is the same blob (bfcfe14024) on both branches: #1232 landed in checker/{calls,control,core,resolution}.py and wasm/calls_handlers.py, #1238 in naming.py, codegen/* and types.py.

Trial-rebased onto release/v0.1.10: zero code conflicts, only doc counts collide, and tests/test_lsp.py auto-merges because both sides are non-overlapping additions. On that base, the LSP and obligations suites passed (853 tests on the post-release main after the maintainer-side rebase; the full suite is green there too), mypy clean, all 11 handler-bounding legs green, the refinement E125 case still reproducing, and mutation M4 still taking both refinement tests RED.

You were right that the ground moved, and it moved in this PR's favour. #1238 is what made naming.family_name render refinements distinguishably (Exn<{Int|@Int.0 >= 0}> instead of collapsing to Exn<@Int>), which is a better key than the guard here: mine makes every refinement handler unmatchable, so it under-prunes rather than over-prunes. vera/naming.py doesn't exist on main, so it can't be used from this PR, but once v0.1.10 lands it would delete the refinement branch, the sentinel, and _effect_instance_key outright. Happy to do that rebase whenever you want it.

@chethanuk
chethanuk requested a review from aallan as a code owner August 8, 2026 07:41
@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The LSP workflow now stops vera/addEffect propagation at matching handled effect instances. It preserves propagation through unhandled paths, handler clauses, mismatched handlers, and where helpers. Tests and documentation cover the updated behaviour.

Changes

Handler-aware effect propagation

Layer / File(s) Summary
Handler-filtered propagation workflow
vera/lsp/workflows.py
transitive_callers accepts an optional effect and filters calls within matching handler bodies. add_effect passes the effect for handler-aware caller analysis.
Propagation and handler test coverage
tests/test_lsp.py, TESTING.md
Tests cover handled, unhandled, mismatched, foreign-handler, handler-clause, nested-handler, qualified-effect, refined-effect, and where-helper paths. Testing metrics and coverage descriptions are updated.
Documentation and project status updates
CHANGELOG.md, LSP_SERVER.md, KNOWN_ISSUES.md, README.md, ROADMAP.md, FAQ.md, vera/README.md
Documentation records the propagation semantics, removes the completed limitation, and updates project metrics.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant add_effect
  participant transitive_callers
  participant handler_filter
  participant effect_rows
  add_effect->>transitive_callers: Analyse callers for the requested effect
  transitive_callers->>handler_filter: Inspect call sites and handler scopes
  handler_filter-->>transitive_callers: Exclude matching handled calls
  transitive_callers-->>add_effect: Return callers that still require the effect
  add_effect->>effect_rows: Rewrite required top-level effect rows
Loading

Possibly related issues

Possibly related PRs

  • aallan/vera#723: This PR extends the vera/addEffect and transitive_callers workflow.
  • aallan/vera#1202: Both PRs modify handler-aware effect processing and scope traversal.
  • aallan/vera#1232: Both PRs modify handler effect resolution and handler clause behaviour.

Suggested labels: tests, docs, compiler

🚥 Pre-merge checks | ✅ 7 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 70.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (7 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The implementation bounds propagation at matching handle[E] call sites and updates tests and limitation documentation as required by issue #725.
Out of Scope Changes check ✅ Passed The code, tests, documentation, and metrics changes directly support handler-aware vera/addEffect propagation and issue #725.
Changelog Covers Public-Surface Changes ✅ Passed The only public-surface change is in vera/lsp/workflows.py; CHANGELOG.md explicitly describes the vera/addEffect handler bounds, propagation cases, and full effect-instance matching.
Spec And Implementation Move Together ✅ Passed No parser, checker, verifier, codegen, or spec/ files changed. The change is confined to LSP workflow logic, and spec/07-effects.md already defines handler effect discharge.
Diagnostics Carry An Error Code ✅ Passed The PR adds no diagnostic construction or severity path; changed production code only updates LSP workflows, and check_diagnostic_fields.py reports all diagnostics fully tagged.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: bounding vera/addEffect propagation at matching handlers.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Aug 8, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 94.06%. Comparing base (cae2b96) to head (f628d9f).

Additional details and impacted files
@@           Coverage Diff           @@
##             main    #1259   +/-   ##
=======================================
  Coverage   94.05%   94.06%           
=======================================
  Files         100      100           
  Lines       35810    35829   +19     
  Branches      458      458           
=======================================
+ Hits        33682    33701   +19     
  Misses       2115     2115           
  Partials       13       13           
Flag Coverage Δ
javascript 78.61% <ø> (ø)
python 95.68% <100.00%> (+<0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/test_lsp.py`:
- Around line 1049-1066: Add a bare-effect handler fixture alongside
HANDLER_EDGES, using handle[IO] around a call to target, and add a test for
transitive_callers that verifies an IO propagation is bounded while Async still
reaches io_handled. This should exercise the no-type-argument path in
_handled_effect_key and preserve the existing target-only and transitive caller
expectations.
- Around line 1153-1158: Add regression coverage in tests around
transitive_callers: add a whitespace-normalization test using _program(HANDLERS)
and request "State< Int >", then add nested-handler fixtures/tests covering both
a matching handler inside a foreign handler and a foreign handler inside a
matching handler, asserting the matching case prunes while the foreign nested
case does not un-prune.

In `@vera/lsp/workflows.py`:
- Around line 366-375: Update the effect-instance key construction around the
rendered argument loop to detect refinement arguments before calling
format_type_expr and return an unmatchable key when any argument is a
RefinementType. Preserve existing rendering for non-refined arguments, while
ensuring refined effect instances are not collapsed into their base types.

In `@vera/README.md`:
- Line 124: Update the workflows.py code span in the README table to remove
spaces inside the backticks while preserving the surrounding table-cell padding.
- Line 124: Update the README table entry for workflows.py to document the
current 547-line count instead of 502, leaving the surrounding description
unchanged.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 30f4b991-0ebd-404e-b5bf-e91341d986cb

📥 Commits

Reviewing files that changed from the base of the PR and between 500ebab and 7f23559.

⛔ Files ignored due to path filters (1)
  • docs/llms-full.txt is excluded by !docs/**
📒 Files selected for processing (10)
  • CHANGELOG.md
  • FAQ.md
  • KNOWN_ISSUES.md
  • LSP_SERVER.md
  • README.md
  • ROADMAP.md
  • TESTING.md
  • tests/test_lsp.py
  • vera/README.md
  • vera/lsp/workflows.py
🔗 Linked repositories identified

CodeRabbit considers these linked repositories for cross-repo context during reviews:

  • aallan/vera-bench (manual)
💤 Files with no reviewable changes (1)
  • KNOWN_ISSUES.md

Comment thread tests/test_lsp.py
Comment thread tests/test_lsp.py
Comment thread vera/lsp/workflows.py
Comment thread vera/README.md Outdated
Comment thread tests/test_lsp.py
@chethanuk
chethanuk force-pushed the fix/issue-725-lsp-handler-aware-propagation branch from 7f23559 to e0b4d84 Compare August 8, 2026 08:04

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@TESTING.md`:
- Line 173: Escape both literal pipe characters in the inline refinement
examples within the test_lsp.py table row, changing the predicates in Exn<{ `@Int`
| p }> and Exn<Array<{ `@Int` | p }>> to use Markdown-safe escaped pipes while
preserving the surrounding examples.

In `@tests/test_lsp.py`:
- Around line 1212-1217: Extend test_mismatched_type_argument_keeps_the_edge
with a positive-control assertion using the State<Nat> handler fixture,
verifying that a matching State<Nat> request is pruned and does not retain the
caller edge. Keep the existing State<Int> mismatch assertion unchanged so the
test covers both matching and non-matching behavior.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: e8da27f1-a830-41e8-86fa-7cddbffa2861

📥 Commits

Reviewing files that changed from the base of the PR and between 7f23559 and e0b4d84.

⛔ Files ignored due to path filters (1)
  • docs/llms-full.txt is excluded by !docs/**
📒 Files selected for processing (7)
  • FAQ.md
  • README.md
  • ROADMAP.md
  • TESTING.md
  • tests/test_lsp.py
  • vera/README.md
  • vera/lsp/workflows.py
🔗 Linked repositories identified

CodeRabbit considers these linked repositories for cross-repo context during reviews:

  • aallan/vera-bench (manual)

Comment thread TESTING.md Outdated
Comment thread tests/test_lsp.py
@chethanuk
chethanuk force-pushed the fix/issue-725-lsp-handler-aware-propagation branch from e0b4d84 to 4c370c2 Compare August 8, 2026 08:17
@aallan

aallan commented Aug 8, 2026

Copy link
Copy Markdown
Owner

Thanks — and the body's reachability analysis (the handle[Exn<{...}>] shape through type_args, and the E336 interplay) is careful work. Same caveat as #1262 though: the handler and cell-identity machinery this touches was substantially rewritten in the in-flight v0.1.10 work on release/v0.1.10 — PR #1232 changed clause-body op resolution to the enclosing context, and PR #1238 gave refined and fn-type cells their own families, which moves exactly the cell-key ground your refinement-rendering concern stands on. Written against main, this will not survive the release merge. We resume after the weekend; once v0.1.10 lands, a rebase (or a re-derivation against the new family-identity code) is the right path, and the E125 over-pruning case you measured is worth carrying into it.

@chethanuk
chethanuk force-pushed the fix/issue-725-lsp-handler-aware-propagation branch from 4c370c2 to 8bfada7 Compare August 8, 2026 09:55
@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

@chethanuk Confirmed. The docstring change addresses the reported gap.

Keeping the scope to the new test is correct. The pre-existing tests/test_lsp.py coverage does not require unrelated documentation changes in this PR.

Reverting the ruff format output was also correct. This repository enforces ruff check ., not formatter output.


✏️ Learnings added
Learnt from: chethanuk
URL: https://github.com/aallan/vera/pull/1259

Timestamp: 2026-08-08T10:03:28.115Z
Learning: In `aallan/vera`, pre-commit runs `ruff check .` and does not run `ruff format`. Do not request or introduce formatting-only rewrites unless they are required to fix a correctness issue or are explicitly requested.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

You are interacting with an AI system.

@chethanuk
chethanuk force-pushed the fix/issue-725-lsp-handler-aware-propagation branch 2 times, most recently from 8f58918 to 337d580 Compare August 11, 2026 16:54

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
KNOWN_ISSUES.md (1)

44-45: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Update issue #725 before removing this row.

Issue #725 remains open, although this change implements the fix. Add a closing reference; otherwise retain the row until the issue is closed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@KNOWN_ISSUES.md` around lines 44 - 45, Update the affected KNOWN_ISSUES entry
to reference issue `#725` as the closing issue for the implemented fix; retain the
existing row and its current issue reference until the original issue is closed.

Source: Path instructions

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@KNOWN_ISSUES.md`:
- Around line 44-45: Update the affected KNOWN_ISSUES entry to reference issue
`#725` as the closing issue for the implemented fix; retain the existing row and
its current issue reference until the original issue is closed.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: ce550d90-12fb-43c4-8c0b-c8cd5fa7c4df

📥 Commits

Reviewing files that changed from the base of the PR and between 8bfada7 and 337d580.

📒 Files selected for processing (1)
  • KNOWN_ISSUES.md
🔗 Linked repositories identified

CodeRabbit considers these linked repositories for cross-repo context during reviews:

  • aallan/vera-bench (manual)

@chethanuk

Copy link
Copy Markdown
Contributor Author

@aallan You were right that the release merge was the risk, so I trial-rebased onto release/v0.1.10: vera/lsp/workflows.py is untouched there, zero code conflicts, 810 passed, and the mutation check still goes RED. Evidence is in the description. All 27 applicable checks green. Ready for review and merge.

@aallan

aallan commented Aug 13, 2026

Copy link
Copy Markdown
Owner

Review round complete; the branch carries the contributor's commit (rebased onto post-v0.1.10 main, zero code conflicts — main never touched workflows.py) plus one maintainer-side commit, 633b16d5.

Fitness verdict: ACCEPT — no DESIGN violation. The pruning boundary was verified as the compiler's own discharge boundary by execution (a call in a handler's state initialiser fails E125 under a pure caller; the same call in the body is clean — checker synth order and codegen step order both agree), and the bounded closure reaches every LSP client unconditionally, not as an opt-in. Three fixes from the round:

  1. The state-initialiser boundary is now pinned — it was true but unproved, and in the unsafe direction: a mutant that also prunes the initialiser passed all 853 existing tests. The new fixture fails that mutant, with a second test pinning the E125 language fact the rule rests on (mutation-validated both ways).
  2. The docs claimed instance-for-instance matching; the key compares source spellings. Demonstrated: handle[State<MyAlias>] with type MyAlias = Int discharges a State<Int> callee per §7.5.1, yet the State<Int> request keeps the edge. Under-prune (safe direction), now stated honestly at all three doc sites and pinned by test; the resolution through the naming module — which must resolve the request side symmetrically — is vera/addEffect handler bounding compares spellings, not resolved effect instances #1292.
  3. The file-boundary limitation is restored to the LSP_SERVER table (issue-less by-design row) — it had been retired alongside the handler-unawareness limitation this PR actually fixes.

Gates green across the board (10,330 counted tests / 213 conformance / limitation sync at 2 LSP rows). Remaining before merge: the CI wall on this push and the CodeRabbit pass at this head; merge order stays behind #1279 and #1262 per the queue.

@aallan

aallan commented Aug 13, 2026

Copy link
Copy Markdown
Owner

@coderabbitai On the outside-diff finding for KNOWN_ISSUES.md (the #725 row deletion, review at 337d580): Declining — retaining the row would break the project's documented convention rather than preserve it. CLAUDE.md specifies that an issue closed by a PR has its entry deleted in that PR, with the closure effected on merge; closingIssuesReferences resolves to #725, so the row and the issue go away in the same event. The complementary half of the same convention is why LSP_SERVER.md did not simply lose its row: that one stated two limitations and only handler-unawareness is fixed, so a by-design row for the file boundary was restored in this round.

@coderabbitai

coderabbitai Bot commented Aug 13, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- CLAUDE.md convention ---'
if [ -f CLAUDE.md ]; then
  rg -n -i -C 4 'KNOWN_ISSUES|close[sd]?|issue' CLAUDE.md
else
  echo 'CLAUDE.md not present at repository root'
fi

echo '--- KNOWN_ISSUES.md issue 725 context ---'
rg -n -C 3 '`#725`|725|addEffect|add_effect' KNOWN_ISSUES.md || true

echo '--- PR `#1259` closing references ---'
gh pr view 1259 --repo aallan/vera --json closingIssuesReferences \
  --jq '.closingIssuesReferences[] | {number, title, url, state}'

Length of output: 7776


@aallan Confirmed. PR #1259 closes #725, and the repository convention requires removal of the corresponding KNOWN_ISSUES.md Bugs row.

The restored LSP_SERVER.md row is also correct. The file-boundary limitation remains, while this PR removes only the handler-unawareness limitation.

I withdraw the outside-diff finding.


🧠 Learnings used
Learnt from: aallan
Repo: aallan/vera PR: 1175
File: KNOWN_ISSUES.md:11-11
Timestamp: 2026-08-03T11:41:20.860Z
Learning: In `aallan/vera`, `KNOWN_ISSUES.md` Bugs rows correspond one-to-one with open GitHub issues that have the `bug` label. When a pull request closes such an issue, remove its Bugs row in the same pull request.

You are interacting with an AI system.

@aallan

aallan commented Aug 13, 2026

Copy link
Copy Markdown
Owner

Adversarial review record — PR #1259 (four-lens fitness protocol, 2026-08-13)

Reviewed at 1e4d2877 (the contributor's commit rebased onto post-v0.1.10 main; zero code conflicts — main never touched workflows.py). Verdict: ACCEPT with three fixes, all landed in 633b16d5.

Fitness verdict

  • Lens zero, DESIGN.md (veto): none. The pruning rule is not a heuristic that happens to work — it is the compiler's own discharge boundary, proven by execution rather than by docstring: a call in handle[State<Int>]'s body under a pure caller checks clean, while the same call in the handler's state initialiser fails E125 — both backends agreeing structurally (the checker synths init_expr before extending the effect row; codegen translates init before installing the cell). The base<?> sentinel for refinement-bearing handlers is conservative in the direction that still type-checks, documented at its site, and test-pinned — a bounded under-approximation, not implicit behavior.
  • Fit: yes. One file, one function gains one optional argument, reusing the existing walk_nodes walker — exactly the no-new-infrastructure shape LSP: handler-aware vera/addEffect propagation bounding #725 predicted.
  • Addresses LSP: handler-aware vera/addEffect propagation bounding #725: yes, and not as an opt-in. vera/lsp/server.py passes effect into transitive_callers unconditionally — every LSP client gets the bounded closure with zero changes. effect=None is a back-compat helper shape whose only non-test caller doesn't use it.
  • Entirely closes LSP: handler-aware vera/addEffect propagation bounding #725: yes for its content, with one orphaned doc fact repaired (below).

The three fixes

  1. The state-initialiser boundary was true but unproved — in the unsafe direction. A mutant that also prunes h.state passed all 853 existing tests; pruning the initialiser would silently break the workflow's core guarantee (the caller keeps pure, the call site fails E125, the edit is refused). Now pinned: a fixture whose only call sits in the initialiser stays in the closure (RED under the mutant), plus a test pinning the E125 language fact itself, mutation-validated both ways. The docstring's derivation was split — the initialiser has its own reason (evaluated in the enclosing scope before the handler installs), it does not borrow the clause rule.
  2. The docs claimed instance-for-instance matching; the key compares source spellings — and the discriminating case is real. type MyAlias = Int; handle[State<MyAlias>] discharges a State<Int> callee (the checker builds EffectInstance from resolved types, §7.5.1), yet transitive_callers(…, "State<Int>") kept the edge. Strictly an under-prune (safe), but it leaves LSP: handler-aware vera/addEffect propagation bounding #725 unfixed for ~20 alias-spelled handler heads in the corpus, and three doc sites stated the false stronger claim. All corrected to the truth, the alias case pinned as documented behavior, and the real fix — rendering both sides of the comparison through naming.family_name with the module's AliasEnv, request side included — tracked as vera/addEffect handler bounding compares spellings, not resolved effect instances #1292 (the author's own proposed follow-up, now that the v0.1.10 naming machinery exists).
  3. The retired LSP_SERVER limitation row carried two claims and only one was fixed. Handler-unawareness is fixed here; "propagation stops at the file boundary, by design" had silently left the inventory (the sync gate counts rows, not concerns). Restored as an issue-less by-design row.

Soundness probes beyond the centerpiece (each run, not reasoned)

Nested handlers both orders — pruned correctly, including a call in an inner foreign handler's clause inside an outer matching handler's body (the clause runs in the enclosing context, §7.5.2, executably confirmed by the conformance corpus). Clause bodies calling effectful functions — edge kept, matching the checker. Closures — classified exactly as the checker classifies them (an AnonFn body checks against the ambient row; E125 probe both ways). The id(n) two-pass identity — attacked with byte-identical call text inside and outside one handler; frozen dataclasses compare equal but are not interned, and the outside edge survives. handle over an effect row — impossible by grammar (one effect_ref), non-issue by construction. The refinement sentinel — its defended shape is reachable (handle[Exn<{ @Int | … }>] type-checks and discharges; E125 probe) and the key survey confirms only refinement-bearing arguments collapse to the unmatchable form. All seven embedded fixtures re-verified vera check clean against the post-v0.1.10 checker.

Reviewer: Opus subagent, four-lens fitness protocol, DESIGN veto first.

chethanuk and others added 2 commits August 13, 2026 13:19
The transitive-caller closure was handler-unaware: a caller that
wrapped its call in handle[E] had E appended to its own effects(...)
row even though the handler discharges it there, so the workflow wrote
rows the program does not need and dragged that caller's own callers in
behind it.

A call site inside a handle[E] body now contributes no edge.
transitive_callers takes an optional effect argument (None keeps the
old handler-unaware closure, which the existing goldens still pin), and
_unhandled_callee_names subtracts the handled sub-tree from
direct_callee_names.

Two cases deliberately still propagate, because the effect really does
escape them: a caller that reaches the callee on any unhandled path as
well, and a call in a handler clause body, which runs outside its own
handler. Both are pinned by tests, and reverting either half of the
bound turns the matching test red.

Containment is structural rather than span arithmetic - identical
answers where both apply, no special case for a node with no span, and
it reuses the existing walk_nodes walker.

The handler has to name the same effect instance, type arguments and
all. The checker discharges against EffectInstance, whose equality
includes type_args, so handle[State<Nat>] does not discharge
State<Int>: pruning that edge on a base-name match would leave the
caller pure, never write the row it actually needs, and fail the whole
candidate on E125. Only an exact match prunes, and every other outcome
keeps the edge - a surviving edge writes a row the program may not
strictly need, which still type-checks. Row identity is a separate
question and stays the base name, so State<Int> is still not appended
beside an existing State<Bool>.  Asking the same fixture for
State<Nat> is the positive control: that handler key does prune, so
the surviving State<Int> edge is attributable to the type argument
and not to a key nothing can match.

A refinement argument is one of those distinct instances, and the
narrowest way to get this wrong: format_type_expr renders
{ @int | p } as its bare base type, so a key built straight from it
spells Exn<Int> for a handler that discharges nothing of Exn<Int>, and
the pruned caller would keep pure while its call site fails E125. Any
argument holding a refinement - nested inside another type argument
included, since the renderer recurses - is spelled unmatchably
instead, so the edge survives. handle[Exn<{ @int | p }>] and
handle[Exn<Array<{ @int | p }>>] both check clean, so both are pinned,
end-to-end as well as at the closure.

The two remaining branches of the key are pinned too: an
unparameterised handle[IO] bounding an IO propagation (IO and Async are
what addEffect propagates most), whitespace-insensitive request
spelling, and handler nesting in both orders - a matching handler
inside a foreign one still bounds, and a foreign one inside a matching
one does not un-bound. handle[Mod.IO] is pinned at the key rather than
through a program: effects are only ever registered under an
unqualified name, so a qualified handler always fails E330 and no
program in which that key could prune ever type-checks.

where-block attribution is unchanged: a helper's bare call still
attributes to its containing top-level function, while a helper that
discharges the effect itself bounds the closure at its parent. Row
rewriting is also unchanged, and still top-level-only - a where helper
that needs the new effect does not get one, and the gate refuses that
candidate rather than applying it half-done.

The KNOWN_ISSUES.md and LSP_SERVER.md limitation rows are retired.
Three review fixes on the handler bound. They share lines in the
module docstring and the CHANGELOG bullet, so they land together.

The handler's STATE INITIALISER was documented as escaping the bound
for the clause rule's reason, and nothing pinned it: patching
_unhandled_callee_names to prune h.state as well left the whole suite
green. It has its own reason - the initialiser is evaluated in the
ENCLOSING scope, before the handler is installed, which is why
_check_handle synths state.init_expr before it extends
env.current_effect_row and why _translate_handle_state evaluates it
before pushing the cell. The docstring now derives the two boundaries
separately, and two tests pin them: the closure keeps an edge whose
only call site is an initialiser (with a body call under the same
handler spelling as the positive control that the key does prune),
and the checker raises E125 on that call against a pure caller while
the identical call in the handler body is clean.

The comparison was documented as instance-for-instance in three
places. It is not: format_type_expr does not resolve aliases, so the
key compares the handle[...] head's SOURCE SPELLING to the request
string, and handle[State<MyAlias>] with type MyAlias = Int does not
bound a State<Int> propagation even though the checker discharges it.
That under-prunes - the caller keeps a row it does not need, which
still type-checks - so it is the safe direction and the key is left
alone; aallan#1292 owns the swap onto resolved instances. The module
docstring, the transitive_callers docstring and LSP_SERVER.md now say
spelling, name the under-prune, and cite aallan#1292, and a test pins both
halves on one fixture: the program is error-free (only possible if
the alias-spelled handler discharges the State<Int> row its callee
declares) and the State<Int> request keeps the edge, with the
alias-spelled request as the control that does prune.

The retired LSP_SERVER.md limitation row stated two things and only
one of them is fixed, so the file boundary comes back as its own row.
It carries no issue link, being deliberate behaviour rather than
tracked work, with a note above the table saying so.

Counts and llms-full.txt are refreshed from their oracles.

Co-Authored-By: Claude <noreply@anthropic.invalid>
@aallan
aallan force-pushed the fix/issue-725-lsp-handler-aware-propagation branch from 633b16d to f628d9f Compare August 13, 2026 12:28
@aallan
aallan merged commit a96ce50 into aallan:main Aug 13, 2026
28 checks passed
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.

LSP: handler-aware vera/addEffect propagation bounding

2 participants