Skip to content

Commit 79f2f93

Browse files
hyperpolymathclaude
andcommitted
fix(borrow): close #554 use-after-move through a callee-returned borrow
A call whose result borrows one of its arguments now registers the borrow-graph edge that was missing. Each function carries a return-borrow summary (compute_ret_borrow_params): the parameter indices whose borrow may flow out via the return value (a returned `&p` / `return p` for a `ref`/`mut` parameter, directly, via a let-bound ref-local chain, or through an `if`/`match`/block tail in return position). At a call site those argument borrows stay live exactly like a plain `&` borrow — claimed into the result binder (NLL-governed) or lingering to block exit — so `let r = pick(a); consume(a); *r` is now MoveWhileBorrowed while the legitimate NLL reorderings still pass. The control (plain double-consume) and the whole pre-existing valid corpus are unaffected: only calls to functions that actually return a borrow of a parameter change behaviour. Hardened across two adversarial-verification rounds: the aggregate-storage, assign-path, summary-via-arm-tail, and `return`-statement-form holes a first cut missed were all found and closed. Residuals — interprocedural-through- call-result; the branch-merged / copy-out claim (proven `&`-symmetric, a pre-existing lexical limitation inherited not introduced); and a reassign old-loan over-rejection (sound direction) — are documented in-code and closed properly by Polonius (#553); the mechanized proof is tracked as #515. 7 regression fixtures + Alcotest cases added; full suite 393/393 green. ADR-022 reframed (residual = soundness, not precision; status ratified). Closes #554. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 43df11f commit 79f2f93

10 files changed

Lines changed: 637 additions & 24 deletions

docs/decisions/0022-polonius-origin-variables.adoc

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
66
:toclevels: 2
77
:icons: font
88

9-
Status:: Proposed
9+
Status:: Accepted (architecture ratified in PR #407 thread, 2026-05-27; staged implementation tracked in #553)
1010
Date:: 2026-05-26
1111
Issue:: https://github.com/hyperpolymath/affinescript/issues/177[CORE-01 #177]
1212
Series:: Settled-decisions ledger; companion entry in `.machine_readable/6a2/META.a2ml`.
@@ -36,7 +36,14 @@ type. The checker can prove what it sees locally; it cannot reason about:
3636

3737
* Cross-function borrows that outlive a returned struct field. The current
3838
return-escape rule (`callee_owned_params`) is a name-set heuristic, not
39-
a constraint discharge.
39+
a constraint discharge. This residual is *soundness*, not merely
40+
precision: issue #554 exhibited a probe-verified false negative
41+
(`let r = pick(a); consume(a); *r` — a use-after-move through a
42+
callee-returned borrow — accepted end-to-end). It was closed in the
43+
lexical checker on 2026-06-14 by per-function return-borrow summaries
44+
(`compute_ret_borrow_params`, `lib/borrow.ml`); the full constraint-based
45+
discharge of the general interprocedural case remains this ADR's job
46+
(#553), and its mechanized proof is #515.
4047
* True reborrows through indirection (`r2 = r1` where `r1` is itself a
4148
ref-binder, RHS is not a direct `&place`) — see the matching residual
4249
TODO immediately above the Polonius one (`record_ref_binding` does not

lib/borrow.ml

Lines changed: 319 additions & 22 deletions
Large diffs are not rendered by default.
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
// SPDX-License-Identifier: MPL-2.0
2+
// SPDX-FileCopyrightText: 2026 hyperpolymath
3+
//
4+
// #554 — aggregate-storage variant (found by adversarial verification). A
5+
// callee-returned borrow stored into a tuple (record / array behave the
6+
// same) must keep the aliased argument borrowed: storing `pick(b)` into a
7+
// tuple, moving `b`, then reading `*(pair.0)` is a use-after-move and must
8+
// be rejected. A first cut released unclaimed escaping borrows at statement
9+
// end, which silently dropped protection here; the borrow now lingers like
10+
// a plain `&` borrow until block exit.
11+
12+
module BorrowCalleeReturnedBorrowAggregate;
13+
14+
fn pick(ref x: Int) -> ref Int {
15+
return &x;
16+
}
17+
18+
fn consume(own v: Int) -> Int {
19+
return v;
20+
}
21+
22+
fn main() -> Int {
23+
let b: Int = 9;
24+
let pair = (pick(b), 0);
25+
let _gone = consume(b);
26+
let v = *(pair.0);
27+
return v;
28+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
// SPDX-License-Identifier: MPL-2.0
2+
// SPDX-FileCopyrightText: 2026 hyperpolymath
3+
//
4+
// #554 — summary-via-arm-tail variant (found by adversarial verification).
5+
// The return-borrow summary must recognise a borrow returned through a bare
6+
// `match` (or `if`) arm tail, not only a direct `return &x`. Here `pickm`
7+
// returns `&x` via both match arms, so a caller that moves the argument
8+
// while the result is live must be rejected. The summary walker treats
9+
// arm-tail positions as return positions (walk_tail).
10+
11+
module BorrowCalleeReturnedBorrowMatchArm;
12+
13+
fn pickm(ref x: Int, flag: Bool) -> ref Int {
14+
match flag {
15+
true => &x,
16+
false => &x,
17+
}
18+
}
19+
20+
fn consume(own v: Int) -> Int {
21+
return v;
22+
}
23+
24+
fn main() -> Int {
25+
let a: Int = 7;
26+
let r = pickm(a, true);
27+
let _gone = consume(a);
28+
let v = *r;
29+
return v;
30+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// SPDX-License-Identifier: MPL-2.0
2+
// SPDX-FileCopyrightText: 2026 hyperpolymath
3+
//
4+
// #554 anti-over-rejection guard. Same shape as the use-after-move probe,
5+
// but `*r` is read BEFORE `a` is moved. Under NLL last-use, `r`'s borrow on
6+
// `a` is released after `let v = *r` (r's last mention), so moving `a`
7+
// afterwards is legal. Must pass — the #554 fix must not degrade into a
8+
// lexical-only over-rejection that keeps the callee-returned borrow alive
9+
// to block exit.
10+
11+
module BorrowCalleeReturnedBorrowNllOk;
12+
13+
fn pick(ref x: Int) -> ref Int {
14+
return &x;
15+
}
16+
17+
fn consume(own v: Int) -> Int {
18+
return v;
19+
}
20+
21+
fn main() -> Int {
22+
let a: Int = 7;
23+
let r = pick(a);
24+
let v = *r;
25+
let _gone = consume(a);
26+
return v;
27+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// SPDX-License-Identifier: MPL-2.0
2+
// SPDX-FileCopyrightText: 2026 hyperpolymath
3+
//
4+
// #554 — assign-path variant (found by adversarial verification). Re-binding
5+
// a mutable ref-binder to a callee-returned borrow (`r = other(b)`) must keep
6+
// `b` borrowed for the lifetime of `r`: moving `b` and then reading `*r` is a
7+
// use-after-move and must be rejected. The escaping borrow lingers on the
8+
// borrow set exactly as a plain `&b` would.
9+
10+
module BorrowCalleeReturnedBorrowReassign;
11+
12+
fn pick(ref x: Int) -> ref Int {
13+
return &x;
14+
}
15+
16+
fn other(ref y: Int) -> ref Int {
17+
return &y;
18+
}
19+
20+
fn consume(own v: Int) -> Int {
21+
return v;
22+
}
23+
24+
fn main() -> Int {
25+
let a: Int = 1;
26+
let b: Int = 2;
27+
let mut r = pick(a);
28+
r = other(b);
29+
let _gone = consume(b);
30+
let v = *r;
31+
return v;
32+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
// SPDX-License-Identifier: MPL-2.0
2+
// SPDX-FileCopyrightText: 2026 hyperpolymath
3+
//
4+
// #554 — return-STATEMENT-through-branch variant (found by second-pass
5+
// adversarial verification, and the most idiomatic spelling). A borrow
6+
// returned via `return if d { &x } else { &x };` must register in the
7+
// return-borrow summary just like the bare-tail form `if d { &x } else { &x }`.
8+
// The summary walker treats a returned value as a tail position regardless of
9+
// where the `return` keyword sits (walk_expr's ExprReturn delegates to
10+
// walk_tail). Moving the argument while the result is live must be rejected.
11+
12+
module BorrowCalleeReturnedBorrowReturnStmt;
13+
14+
fn pickr(ref x: Int, d: Bool) -> ref Int {
15+
return if d { &x } else { &x };
16+
}
17+
18+
fn consume(own v: Int) -> Int {
19+
return v;
20+
}
21+
22+
fn main() -> Int {
23+
let a: Int = 7;
24+
let r = pickr(a, true);
25+
let _gone = consume(a);
26+
let v = *r;
27+
return v;
28+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// SPDX-License-Identifier: MPL-2.0
2+
// SPDX-FileCopyrightText: 2026 hyperpolymath
3+
//
4+
// #554 — soundness regression. A use-after-move through a callee-returned
5+
// borrow must be rejected. `pick` returns a borrow of its `ref` (caller-
6+
// owned) parameter, so `let r = pick(a)` makes `r` borrow `a`; moving `a`
7+
// (into the `own` parameter of `consume`) while `r` is still live — its
8+
// last use `*r` comes later — must raise MoveWhileBorrowed. Pre-fix this
9+
// passed the full pipeline ("Type checking passed") because the borrow
10+
// graph had no edge from `r` to `a`: the loan returned by `pick` was
11+
// invisible.
12+
13+
module BorrowCalleeReturnedBorrowUam;
14+
15+
fn pick(ref x: Int) -> ref Int {
16+
return &x;
17+
}
18+
19+
fn consume(own v: Int) -> Int {
20+
return v;
21+
}
22+
23+
fn main() -> Int {
24+
let a: Int = 7;
25+
let r = pick(a);
26+
let _gone = consume(a);
27+
let v = *r;
28+
return v;
29+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// SPDX-License-Identifier: MPL-2.0
2+
// SPDX-FileCopyrightText: 2026 hyperpolymath
3+
//
4+
// #554 precision guard. `read_ref` takes a `ref` parameter but returns a
5+
// *value* (`*a`), not a borrow — so its return-borrow summary is empty and
6+
// the call-argument borrow is still released at call end (pre-existing
7+
// behaviour). The argument `a` therefore stays movable afterwards. Must
8+
// pass: the fix must scope strictly to callees that actually return a
9+
// borrow of a parameter.
10+
11+
module BorrowCalleeValueReturnOk;
12+
13+
fn read_ref(ref a: Int) -> Int {
14+
return *a;
15+
}
16+
17+
fn consume(own v: Int) -> Int {
18+
return v;
19+
}
20+
21+
fn main() -> Int {
22+
let a: Int = 7;
23+
let y = read_ref(a);
24+
let _g = consume(a);
25+
return y;
26+
}

test/test_e2e.ml

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5155,6 +5155,101 @@ let test_slice_d_captured_nonlinear_ok () =
51555155
spuriously rejected — the new rule must scope to \
51565156
@linear only: " ^ Borrow.format_borrow_error e)
51575157

5158+
(* #554 — soundness: use-after-move through a callee-returned borrow.
5159+
`pick(ref x) -> ref Int` returns a borrow of its parameter, so
5160+
`let r = pick(a)` makes `r` borrow `a`. Moving `a` while `r` is still
5161+
live must be rejected; pre-fix the borrow graph had no `r -> a` edge so
5162+
the move slipped past the full pipeline. Three tests pin: (1) the
5163+
use-after-move is now caught (MoveWhileBorrowed); (2) the NLL-reordered
5164+
form (read `*r` before the move) still passes — no lexical over-
5165+
rejection; (3) a `ref`-param callee that returns a *value* leaves the
5166+
argument movable (empty return-borrow summary). *)
5167+
let test_borrow_callee_returned_borrow_uam () =
5168+
match borrow_result (fixture "borrow_callee_returned_borrow_uam.affine") with
5169+
| Error (Borrow.MoveWhileBorrowed _) -> ()
5170+
| Error e ->
5171+
Alcotest.fail ("#554: expected MoveWhileBorrowed (move of `a` while the \
5172+
callee-returned borrow held by `r` is live), got: "
5173+
^ Borrow.format_borrow_error e)
5174+
| Ok () ->
5175+
Alcotest.fail "#554 regressed: use-after-move through a callee-returned \
5176+
borrow was accepted — the result binder did not borrow \
5177+
the argument"
5178+
5179+
let test_borrow_callee_returned_borrow_nll_ok () =
5180+
match borrow_result (fixture "borrow_callee_returned_borrow_nll_ok.affine") with
5181+
| Ok () -> ()
5182+
| Error e ->
5183+
Alcotest.fail ("#554 anti-over-rejection: reading `*r` before moving `a` \
5184+
must pass under NLL last-use, got: "
5185+
^ Borrow.format_borrow_error e)
5186+
5187+
let test_borrow_callee_value_return_ok () =
5188+
match borrow_result (fixture "borrow_callee_value_return_ok.affine") with
5189+
| Ok () -> ()
5190+
| Error e ->
5191+
Alcotest.fail ("#554 precision: a `ref`-param callee that returns a \
5192+
*value* has an empty return-borrow summary, so the arg \
5193+
stays movable, got: " ^ Borrow.format_borrow_error e)
5194+
5195+
(* #554 hardening — three variants found by adversarial verification that a
5196+
first cut of the fix missed. All must be rejected: (1) the callee-returned
5197+
borrow stored into an aggregate (tuple) must still keep the arg borrowed;
5198+
(2) reassigning a mutable ref-binder to a callee-returned borrow must keep
5199+
the new target borrowed; (3) a borrow returned through a bare `match` arm
5200+
tail must register in the return-borrow summary. *)
5201+
let test_borrow_callee_returned_borrow_aggregate () =
5202+
match borrow_result (fixture "borrow_callee_returned_borrow_aggregate.affine") with
5203+
| Error (Borrow.MoveWhileBorrowed _) -> ()
5204+
| Error e ->
5205+
Alcotest.fail ("#554 aggregate: expected MoveWhileBorrowed (move of `b` \
5206+
while the borrow stored in the tuple is live), got: "
5207+
^ Borrow.format_borrow_error e)
5208+
| Ok () ->
5209+
Alcotest.fail "#554 aggregate regressed: a callee-returned borrow stored \
5210+
into a tuple did not keep the argument borrowed — \
5211+
use-after-move accepted"
5212+
5213+
let test_borrow_callee_returned_borrow_reassign () =
5214+
match borrow_result (fixture "borrow_callee_returned_borrow_reassign.affine") with
5215+
| Error (Borrow.MoveWhileBorrowed _) -> ()
5216+
| Error e ->
5217+
Alcotest.fail ("#554 reassign: expected MoveWhileBorrowed (move of `b` \
5218+
while `r = other(b)` holds its borrow), got: "
5219+
^ Borrow.format_borrow_error e)
5220+
| Ok () ->
5221+
Alcotest.fail "#554 reassign regressed: reassigning a ref-binder to a \
5222+
callee-returned borrow did not keep the new target \
5223+
borrowed — use-after-move accepted"
5224+
5225+
let test_borrow_callee_returned_borrow_match_arm () =
5226+
match borrow_result (fixture "borrow_callee_returned_borrow_match_arm.affine") with
5227+
| Error (Borrow.MoveWhileBorrowed _) -> ()
5228+
| Error e ->
5229+
Alcotest.fail ("#554 match-arm summary: expected MoveWhileBorrowed (the \
5230+
borrow returned via the match arm must be summarised), \
5231+
got: " ^ Borrow.format_borrow_error e)
5232+
| Ok () ->
5233+
Alcotest.fail "#554 match-arm summary regressed: a borrow returned via a \
5234+
bare match-arm tail was not recorded in the return-borrow \
5235+
summary — use-after-move accepted"
5236+
5237+
(* #554 — the idiomatic `return if/match { ... };` spelling. The returned
5238+
value is in tail position no matter where `return` sits, so the summary
5239+
must still record the arm-tail borrow. A first cut walked the returned
5240+
value as a non-tail expression and missed it. *)
5241+
let test_borrow_callee_returned_borrow_return_stmt () =
5242+
match borrow_result (fixture "borrow_callee_returned_borrow_return_stmt.affine") with
5243+
| Error (Borrow.MoveWhileBorrowed _) -> ()
5244+
| Error e ->
5245+
Alcotest.fail ("#554 return-stmt summary: expected MoveWhileBorrowed (a \
5246+
borrow returned via `return if d { &x } else { &x };` \
5247+
must be summarised), got: " ^ Borrow.format_borrow_error e)
5248+
| Ok () ->
5249+
Alcotest.fail "#554 return-stmt summary regressed: a borrow returned via \
5250+
a `return <branch>;` statement was not recorded in the \
5251+
return-borrow summary — use-after-move accepted"
5252+
51585253
let borrow_tests = [
51595254
Alcotest.test_case "BorrowOutlivesOwner: &local escapes its block"
51605255
`Quick test_borrow_outlives_owner;
@@ -5212,6 +5307,20 @@ let borrow_tests = [
52125307
`Quick test_slice_d_captured_linear_param_rejected;
52135308
Alcotest.test_case "Slice D anti-regression: non-linear capture still OK (#177 pt3)"
52145309
`Quick test_slice_d_captured_nonlinear_ok;
5310+
Alcotest.test_case "#554: use-after-move through callee-returned borrow rejected"
5311+
`Quick test_borrow_callee_returned_borrow_uam;
5312+
Alcotest.test_case "#554 anti-over-rejection: NLL reorder of callee-returned borrow OK"
5313+
`Quick test_borrow_callee_returned_borrow_nll_ok;
5314+
Alcotest.test_case "#554 precision: value-returning ref-param callee leaves arg movable"
5315+
`Quick test_borrow_callee_value_return_ok;
5316+
Alcotest.test_case "#554 aggregate: callee-returned borrow stored in tuple keeps arg borrowed"
5317+
`Quick test_borrow_callee_returned_borrow_aggregate;
5318+
Alcotest.test_case "#554 reassign: r = call() keeps the new target borrowed"
5319+
`Quick test_borrow_callee_returned_borrow_reassign;
5320+
Alcotest.test_case "#554 summary: borrow returned via match-arm tail is tracked"
5321+
`Quick test_borrow_callee_returned_borrow_match_arm;
5322+
Alcotest.test_case "#554 summary: `return if/match {..};` statement form is tracked"
5323+
`Quick test_borrow_callee_returned_borrow_return_stmt;
52155324
]
52165325

52175326
(* ============================================================================

0 commit comments

Comments
 (0)