Skip to content

Memory-safety: generalize borrow-escape rule + loop-escape promotion + EXTERN STRUCT CLOSE#47

Open
cuzzo wants to merge 21 commits into
masterfrom
hotfix-bg-promise-captures
Open

Memory-safety: generalize borrow-escape rule + loop-escape promotion + EXTERN STRUCT CLOSE#47
cuzzo wants to merge 21 commits into
masterfrom
hotfix-bg-promise-captures

Conversation

@cuzzo
Copy link
Copy Markdown
Owner

@cuzzo cuzzo commented May 9, 2026

Summary

Started as a multi-bug hotfix; grew into a substantive memory-safety release after we discovered a use-after-free in MAL whose root cause was a missing universal borrow-escape rule.

Conceptual changes

1. src/annotator.rb — Universal borrow-escape rejection (new)

Every borrow-introducing AST construct now feeds into a single helper, mark_borrow_binding_non_escaping!, which sets both non_escaping (rejects RETURN / container-store) and borrowed_alias (rejects BG / lambda / stream capture) when the payload is non-Copy.

Sites covered:

  • WITH ... AS alias (pre-existing)
  • IF expr AS x (was conditional, now unconditional for non-Copy)
  • MATCH v AS s (new)
  • WHILE expr AS s (new)
  • FOR x IN coll (new)
  • MATCH v { Variant{a, b} -> ... } and IF expr AS { a, b } struct destructure (new)
  • coll AS $name (pipeline AS-binding) (new)

visit_ReturnNode's previously WITH-only return-of-borrow rejection is now universal. Walks GetField/GetIndex chains via ifbind_source_root to catch pp.p.left-style chains.

3 new diagnostic codes: RETURN_OF_BORROW, RETURN_FIELD_OF_BORROW, RETURN_INDEX_OF_BORROW. WITH-specific codes preserved for in-WITH context.

Postmortem: docs/postmortems/383_uaf_match_as_borrow_returned.md. Minimal repro: transpile-tests/_xfail/383_uaf_match_as_borrow_returned.cht.

2. src/mir/control_flow.rb — Loop-escape promotion

Loop-local frame values (lists, maps, arrays, strings) that escape into outer containers are now heap-promoted at declaration time. Without this, the per-iteration frame rewind invalidates the pointer the outer container holds, producing UAF.

3. src/mir/mir_lowering.rb + src/annotator-helpers/function_analysis.rb — Mutable value params by reference

Semantic change. MUTABLE x: Int64 parameters now lower to *i64 at the Zig level with a writeback defer on every exit path, so callee mutations propagate to the caller. Call sites pass &binding. New mutable_ref_target flag on SymbolEntry forces Zig var storage (so &binding yields *T, not *const T).

Two existing specs updated to match the new behavior (mir_lowering_spec.rb, generics_spec.rb).

4. src/ast/schemas.rb — EXTERN STRUCT CLOSE fields fallback

as_struct_schema and as_resource_schema now accept the bare-key hash shape produced by visit_ExternStructDecl, so EXTERN STRUCT { ... } CLOSE "deinit" resolves field accesses correctly.

Test surface

  • spec/transpiler_spec.rb: 7 new specs under MATCH-AS borrow escape (memory safety) covering identifier / field / index / FOR-EACH / MATCH-destructure / BG-capture / RETURN COPY positive case
  • spec/schemas_spec.rb (new): 7 unit tests for the schema fallbacks
  • spec/loop_frame_analysis_spec.rb: 4 new specs for escape-into-outer-container scenarios
  • spec/annotator_spec.rb: 1 new spec for EXTERN STRUCT CLOSE field access
  • transpile-tests/382_mutable_param_updates_caller.cht (new): mutable-param-by-reference contract
  • 7 transpile-tests updated to use RETURN COPY s (the safe form) where they used the unsafe pattern: 119, 160, 162, 174, 175, 176, 179. Test intent (cleanup-plan classification) preserved.
  • 2 specs updated similarly: affine_ownership_spec.rb, cleanup_plan_spec.rb

Verification

  • 4722 examples, 0 failures, 3 pending (the 3 pending are intentional placeholders for known BG-promise-capture limitations)
  • Sorbet typecheck: clean
  • All transpile-tests/*.cht produce valid Zig
  • 0 uncovered changed lines in src/ per simplecov

Test plan

  • Ruby unit specs (parallel)
  • Sorbet typecheck
  • All .cht files in transpile-tests/ transpile cleanly
  • Zig build of all-tests.zig — fails on a pre-existing @hasField/AtomicPtr issue unrelated to this PR; same failure on master baseline
  • examples/mal/interpreter.cht runtime — still does not pass; surfaces a separate INV-1 bug in escape analysis (allocator unification across return branches) filed for follow-up

Known follow-up (NOT in this PR)

examples/mal/interpreter.cht now compile-errors at every unsafe site (correct). With COPY added it transpiles. At runtime, prStr mixes frame-allocated string-concat returns with heap-allocated COPY returns; the caller's cleanup defer assumes one allocator. This is a genuine INV-1 violation that escape analysis should prevent — files for separate compiler work.

Generated with Claude Code

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 9, 2026

🐰 Bencher Report

Branchhotfix-bg-promise-captures
Testbedubuntu-latest

⚠️ WARNING: No Threshold found!

Without a Threshold, no Alerts will ever be generated.

Click here to create a new Threshold
For more information, see the Threshold documentation.
To only post results if a Threshold exists, set the --ci-only-thresholds flag.

Click to view all benchmark results
Benchmarkleak-build-msMeasure (units) x 1e3leak-countMeasure (units)leak-run-msMeasure (units)
benchmarks/concurrent/04_fanout_fanin/bench📈 view plot
⚠️ NO THRESHOLD
5.48 units x 1e3📈 view plot
⚠️ NO THRESHOLD
0.00 units📈 view plot
⚠️ NO THRESHOLD
3,589.46 units
benchmarks/concurrent/09_kvstore/bench📈 view plot
⚠️ NO THRESHOLD
5.35 units x 1e3📈 view plot
⚠️ NO THRESHOLD
0.00 units📈 view plot
⚠️ NO THRESHOLD
60,004.19 units
benchmarks/concurrent/14_nested_lock/bench📈 view plot
⚠️ NO THRESHOLD
5.29 units x 1e3📈 view plot
⚠️ NO THRESHOLD
0.00 units📈 view plot
⚠️ NO THRESHOLD
412.00 units
benchmarks/inter-clear/02_concurrent_fsm_vs_stackful/bench_fsm📈 view plot
⚠️ NO THRESHOLD
5.31 units x 1e3📈 view plot
⚠️ NO THRESHOLD
0.00 units📈 view plot
⚠️ NO THRESHOLD
167.73 units
benchmarks/inter-clear/02_concurrent_fsm_vs_stackful/bench_stackful📈 view plot
⚠️ NO THRESHOLD
5.29 units x 1e3📈 view plot
⚠️ NO THRESHOLD
0.00 units📈 view plot
⚠️ NO THRESHOLD
194.28 units
benchmarks/sequential/11_pipeline_overhead/bench📈 view plot
⚠️ NO THRESHOLD
5.24 units x 1e3📈 view plot
⚠️ NO THRESHOLD
0.00 units📈 view plot
⚠️ NO THRESHOLD
14,016.45 units
benchmarks/server/02_json_api/server📈 view plot
⚠️ NO THRESHOLD
5.38 units x 1e3📈 view plot
⚠️ NO THRESHOLD
0.00 units📈 view plot
⚠️ NO THRESHOLD
1,002.80 units
🐰 View full continuous benchmarking report in Bencher

@codecov-commenter
Copy link
Copy Markdown

codecov-commenter commented May 9, 2026

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 86.90476% with 22 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.12%. Comparing base (9b7cfa2) to head (33fe517).
⚠️ Report is 2 commits behind head on master.

Files with missing lines Patch % Lines
src/ast/type.rb 73.52% 9 Missing ⚠️
src/mir/mir_lowering.rb 71.42% 8 Missing ⚠️
src/annotator.rb 92.85% 4 Missing ⚠️
src/annotator-helpers/capabilities.rb 83.33% 1 Missing ⚠️
❗ Your organization needs to install the Codecov GitHub app to enable full functionality.
Additional details and impacted files
@@            Coverage Diff             @@
##           master      #47      +/-   ##
==========================================
+ Coverage   90.01%   90.12%   +0.10%     
==========================================
  Files         185      185              
  Lines       50226    50363     +137     
  Branches    12044    12139      +95     
==========================================
+ Hits        45212    45388     +176     
+ Misses       5014     4975      -39     
Flag Coverage Δ
ruby 87.00% <86.90%> (+0.17%) ⬆️
zig 95.59% <ø> (ø)

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

☔ View full report in Codecov by Sentry.
📢 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.

cuzzo and others added 6 commits May 9, 2026 19:12
Modernizes the MAL example for current CLEAR syntax, fallible-return discipline, ownership copies, and pool env access patterns.

This is compile-only: the interpreter still needs follow-up work before its runtime assertions pass correctly.
… forms

The compiler had two specialized rules — RETURN-of-WITH-alias and IF-AS
non_escaping propagation — that left a memory-safety hole: borrows
extracted via MATCH-AS, WHILE-AS, FOR-EACH, struct destructure, or
pipeline AS could be returned, container-stored, or BG-captured with no
diagnostic, producing UAF.

Discovered while debugging examples/mal/interpreter.cht's "tagged pair
car" test: prStr's `Value.Symbol AS s -> RETURN s;` returned a slice
into a caller-owned Value whose cleanup-defer fired before the function
actually returned. Caller dereferenced freed memory.

Universal rule replacing the specialized ones:

  Any binding produced by a borrow-extracting pattern (WITH-AS,
  IF-AS, MATCH-AS, WHILE-AS, FOR-EACH, struct destructure in
  IF/MATCH, pipeline `AS $name`) is marked non_escaping AND
  borrowed_alias when the payload is non-Copy. visit_ReturnNode
  rejects RETURN of any chain rooted in such a binding (identifier,
  GetField, GetIndex) regardless of WITH context. The existing
  has_non_escaping_capture check rejects fiber capture via the same
  flag.

This is mostly subtractive: the @with_block_depth>0 gate is lifted,
the IF-AS conditional propagation is replaced with unconditional, and
a single helper (mark_borrow_binding_non_escaping!) becomes the
single source of truth. New diagnostic codes RETURN_OF_BORROW,
RETURN_FIELD_OF_BORROW, RETURN_INDEX_OF_BORROW for non-WITH cases;
existing WITH codes preserved for in-WITH context.

Tests / specs updated to use `RETURN COPY s` (the safe form) where
they used the unsafe pattern: 7 transpile-tests (119, 160, 162, 174,
175, 176, 179) plus spec/affine_ownership_spec.rb and
spec/cleanup_plan_spec.rb. The test intent (cleanup-plan classification)
is preserved.

Regression artifacts:
  - transpile-tests/_xfail/383_uaf_match_as_borrow_returned.cht:
    minimal 25-line runtime repro; lives outside the auto-glob.
  - spec/transpiler_spec.rb: 7 new specs covering identifier, field,
    index, BG-capture, FOR-EACH, MATCH-destructure shapes, plus a
    `RETURN COPY` positive case.
  - spec/schemas_spec.rb: 7 unit tests for as_struct_schema and
    as_resource_schema fallbacks (closes 3-line coverage gap from
    the existing PR scope).
  - docs/postmortems/383_uaf_match_as_borrow_returned.md: full
    root-cause writeup, table of every borrow-introducing site,
    LEND-roadmap alignment.

examples/mal/interpreter.cht updated with COPY at every flagged site
so it transpiles cleanly. It still does not run end-to-end: prStr
mixes frame-allocated string-concat returns with heap-allocated COPY
returns, hitting an INV-1 (single-allocator-per-binding) violation
that escape analysis currently does not unify across return branches.
That is a separate compiler bug filed for follow-up.

Verified: 4722 examples / 0 failures / 3 pending; Sorbet clean;
all transpile-tests/*.cht produce valid Zig; 0 uncovered changed
lines in src/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@cuzzo cuzzo changed the title Hotfix bg promise captures Memory-safety: generalize borrow-escape rule + loop-escape promotion + EXTERN STRUCT CLOSE May 10, 2026
cuzzo and others added 13 commits May 10, 2026 12:29
Template-based program generator that drives .cht source through `./clear test`
end-to-end, exercising the MIR static checker (9 invariants) and runtime leak
detection (`std.testing.allocator`).

Templates (90 active cells, 30 reserved :in_dev for unlanded features):
- escape_via_return (18) — E2 :always_returned, :heap_ptr_return
- loop_carry_collection (8) — E2 :loop_carry_string + frame-rewind
- mutable_collection_param (8) — INV-CROSS-FRAME-PARAM-ALLOC
- nested_loop_escape (8) — loop-local list/map escape (commit 9fa2192)
- stream_into_boundary (48) — NEXT × {BG, DO, BG STREAM} × ownership/sync/move/value
- lifetimed_return (6) — bg_lifetime_sources stamping → enforcement check

Cells carry an `expected:` annotation (:pass / :compile_error / :in_dev) so the
matrix accommodates documented capability boundaries (e.g., DO + @Local) and
unlanded features (LEND, @atomic BG-body capture) without losing cell count.

Findings on the current tree:
- 1 leak: (bg_stream, local, copy, String) — heap addresses leak after BG STREAM
  consumer with WHILE TRUE and outer-scope String COPY.
- 2 UNEXPECTED-PASS: BG handle capturing @Local can RETURN or be stored in
  a heap struct field. Compiler accepts; runtime crashes with SIGABRT. Real
  UAF surface — bg_lifetime_sources stamps but doesn't enforce these cases.
- 5 MIR-FAIL: phase B outstanding work (BG-body atomic auto-load, edge case
  in BG+versioned+copy).

docs/agents/formal-verification-testing.md inventories the 12 testing layers,
what each covers, what's intentionally not covered (with reasons), and a
ranked TODO of 13 combinatoric sets to add. Folds in takeaways from an
older draft at ~/manual/clear/docs/agents/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Verifies CLAUDE.md's non-escaping rule on WITH aliases (EXCLUSIVE / BORROWED
/ RESTRICT / SNAPSHOT) against 10 patterns per alias-perm tuple: 2 baselines
(use, RETURN COPY) + 8 escape attempts (return alias, return field, BG / DO
/ BG STREAM capture, TAKES consume, store in heap field, list append).

5 alias-perm tuples × 10 patterns = 50 cells.

Findings on current tree:
- 30/30 escape-attempt cells correctly reject with the right diagnostic
  ("Cannot RETURN 'ref' from inside a WITH block. WITH aliases are
  borrows of locked data and cannot escape their scope."). Solid.
- 16/20 baseline cells pass.
- 4 baseline_copy_return cells fail with Zig codegen error "expected
  error_union, found *T": RETURN COPY ref lowers to a *T pointer
  instead of a Counter value for EXCLUSIVE / RESTRICT / SNAPSHOT
  aliases. Only BORROWED's COPY-return path is correctly lowered.

The unit test spec/with_alias_escape_spec.rb "allows RETURN COPY of an
EXCLUSIVE alias" passes because it stops at annotation. The matrix
running end-to-end through codegen surfaces the type mismatch — exactly
the unit/e2e gap motivating the harness.

Per branch policy: matrix surfaces the bugs; fixes land elsewhere.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A function whose return type is non-Copy String/list/map hands its
result to the caller, who expects to own it on a single allocator
(heap, by convention — what the caller's cleanup defer assumes).
Before this commit, escape analysis only marked the FUNCTION's
return_provenance, never propagating that decision down into the
individual return-position expressions. A function with mixed
branches like

  RETURNS !String ->
    PARTIAL MATCH v START
      Value.Symbol AS s -> RETURN COPY s;,    -- heap (dupe to heap)
      Value.List AS items -> RETURN out + ")";  -- frame (string concat)
    END

would emit `concat(rt.frameAlloc(), ...)` for the List branch while
the caller emits `defer rt.heapAlloc().free(returned_slice)`. INV-1
violation that surfaces as "Invalid free" under the debug allocator.

Surfaced by examples/mal/interpreter.cht's `prStr` after the
borrow-escape rejection landed: prStr was previously dodging this by
returning borrows (no caller-side cleanup at all); once the borrow
return was rejected and rewritten to `RETURN COPY s`, the mixed-
allocator bug became live.

Three pieces:

1. src/mir/escape_analysis.rb — new "Condition 9" in per_fn_scan!:
   if the function's return type (after `!T` unwrap) is a non-Copy
   String/list/map, walk every ReturnNode and call both
   `e2_promote_frame_concats!` (BinaryOp / StringConcat / list &
   struct literals) and `LoopFrameAnalysis.promote_value_to_heap!`
   (FuncCall / MethodCall / Identifier root resolution).

2. src/mir/control_flow.rb — `promote_value_to_heap!` for
   FuncCall/MethodCall now ALSO sets `node.storage = :heap` so the
   intrinsic dispatch's `:node_storage` allocator resolution
   produces `:heap` and the InlineZig substitutes
   `rt.heapAlloc()` for `{alloc}`. Previously it only set
   `heap_dupe_result`, which lower_intrinsic never reaches because
   intrinsics return early.

3. src/mir/mir_lowering.rb — `hoist_cleanup_entry` gains a case for
   allocating `MIR::InlineZig`, deriving the cleanup kind from the
   originating AST node's return type (string / list / non-Copy
   union). Without this, a heap-promoted intrinsic that triggers
   `mir_allocates?` would crash hoist_alloc with
   "unhandled allocating MIR node MIR::InlineZig".

Effect on examples/mal/interpreter.cht: tests 1-57 now run
correctly (arithmetic, def!/let*/if/do, comparison, list/count/
empty?/not, recursion, define/lambda/let/begin, set!, TCO,
vector-ref, cons/car/cdr, quote). String-append (test ~58) still
fails — separate INV-1 bug class around loop-carry strings whose
declared allocator is heap but reassignments produce frame.
Filed as a follow-up.

No spec regressions: 4722 examples / 0 failures / 3 pending.
Sorbet clean. All transpile-tests/*.cht produce valid Zig.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three bugs fixed in this PR (universal borrow-escape; mixed-allocator
returns / INV-1; allocating InlineZig in hoist_cleanup_entry); two
filed for follow-up:

  4. Loop-carry frame->heap binding: a heap-declared binding
     (e.g. `MUTABLE out = getStr(...)`) reassigned from a frame
     string-concat (`out = out + getStr(...)`) violates INV-1 at
     scope-end cleanup. The existing loop_carry_string rule only
     fires for loops with mark_per_iter; this loop body has no
     frame allocs, so Cond 5 is silent. MAL stops at test ~58
     (string-append) on this.

  5. Universal borrow-escape misses plain assignment: the same
     check that catches `RETURN borrow.field` and
     `outer.append(borrow.field)` should catch `x = borrow.field`
     when `x`'s storage is heap. Today the user must add `COPY`
     manually (see eval's `ast = COPY tco.tcoAst`).

The audit rule applied: `COPY` added at borrow-return sites is
correct language semantics, not a workaround. A workaround is
anything that hides a compiler bug behind MAL-level code. None of
the MAL changes in PR #47 fall in that category.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Loop-carry-string allocator mismatch: a binding declared with
storage=:heap (initialized from a heap-returning fn) reassigned from
a frame string-concat would store frame memory in a heap-tagged
binding. The cleanup defer at scope-end then frees frame slice via
heapAlloc.free — INV-1 violation, "Invalid free" under the debug
allocator.

Surfaced by examples/mal/interpreter.cht's `applyNative` string-append
branch:

  IF id == 26 THEN
    MUTABLE out = getStr(evaled[1]);          # heap (getStr returns heap)
    FOR si IN (...) DO
      out = out + getStr(evaled[si]);          # frame concat -> heap binding
    END
    RETURN Value{ Str: COPY out };
  END

Existing Cond 5 (loop_carry_string) handled a subset: same shape but
gated on loops with `mark_per_iter`. The string-append loop body has
no frame-allocating decls of its own, so Cond 5 was silent.

New Cond 10 in escape_analysis.rb#per_fn_scan!:

  1. Walk decls, collect names declared with storage=:heap (or whose
     type already has heap_provenance) and a non-Copy String/list/map
     type.
  2. Walk assignments. For each `name = value` where `name` is in the
     heap_string_decls set, run `e2_promote_frame_concats!` and
     `LoopFrameAnalysis.promote_value_to_heap!` on the value.

This is the broader sibling of Cond 9 (RETURN-site unification): same
underlying rule (binding allocator must be uniform across all stores
into it), applied at every assignment instead of only at returns.

Effect on examples/mal/interpreter.cht: ALL 75 INTERPRETER TESTS
PASSED. (The debug allocator still reports leaks — separate bug class
about COPY-at-return cleanup matching, filed in mal-bugs.md.)

No spec regressions: 4722 examples / 0 failures / 3 pending. Sorbet
clean. All transpile-tests/*.cht produce valid Zig.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Verifies which (callee signature × caller binding) tuples the annotator
admits today. 6 callee forms × 6 caller bindings = 36 cells.

Callees: concrete, SHARED Counter (parametric), REQUIRES c: LOCKED,
REQUIRES c: VERSIONED, REQUIRES c: LOCAL, REQUIRES c: LOCKED | LOCAL.

Callers: @locked, @writeLocked, @versioned, @Local, @multiowned, plain.

Findings on current tree (1 UNEXPECTED-PASS + 12 MIR-FAIL):
- (concrete, @Local) UNEXPECTED-PASS: concrete callee admits @Local
  despite docs/sharing-capabilities.md stating concrete admits plain T
  only. The viralization-risk surface — @Local is structurally a *T
  pointer so admission rules treat it like a plain local.
- SHARED T param rejects @locked / @writeLocked / @versioned short
  forms (3 cells) — short forms don't coerce to @shared:* at call
  sites. Test 349 uses the explicit @shared:locked form throughout.
- WITH MATCH syntax not parsed — "Unknown WITH capability 'MATCH'"
  (5 cells, all REQUIRES LOCKED | LOCAL). CLAUDE.md describes the
  syntax; parser doesn't accept it yet.
- Codegen failures for several legitimately-admitted cells
  (req_locked + @locked, req_versioned + @versioned, req_local +
  @Local) — pointer-deref / error-union-ignored Zig errors. Same
  patterns test 349 uses successfully with full @shared:locked form;
  short-form callers trip a different lowering path.

Per branch policy: matrix surfaces; fixes land elsewhere.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cleanup after the assignment-site allocator unification landed:

- src/mir/escape_analysis.rb: drop the STDERR debug instrumentation
  used to verify Cond 10 fires on applyNative::out and friends.
- docs/agents/mal-bugs.md: bug #4 (loop-carry frame->heap binding)
  marked fixed; new bug #7 filed for the 72 leaks the debug
  allocator reports after all 75 MAL tests pass — sample trace
  points to the COPY-at-borrow-return path in getStr.

MAL state at end of this PR:
  - All 75 interpreter tests PASS
  - 4722 spec examples / 0 failures / 3 pending
  - Sorbet clean
  - 72 DebugAllocator leaks (filed, not addressed)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Verifies the modifier × ownership rules from src/ast/diagnostic_registry.rb:
what can / can't cross BG / DO / BG STREAM with @parallel or @Pinned.

Cross-product:
- boundary  ∈ {bg, do, bg_stream}
- modifier  ∈ {none, @parallel, @Pinned}
- ownership ∈ {@Local, @shared:locked, @multiowned}
= 27 cells.

Findings on the current tree:

Solid enforcement:
- (any boundary, @parallel, @Local)      → correctly rejected (3/3 cells)
- (any boundary, @parallel, @multiowned) → correctly rejected (3/3 cells)
- All BG cells pass (none / @parallel / @Pinned × all 3 ownerships)

Gaps surfaced (8 cells fail end-to-end):
- DO + @Local / DO + @multiowned (modifier none or @Pinned, 4 cells):
  DO branches lower to inner Zig fns that don't close over outer
  @Local / @multiowned bindings. The existing test corpus only uses
  DO with @shared:locked state. Either DO should learn to capture
  these, or docs should clarify that DO requires @shared.
- BG STREAM + @parallel / BG STREAM + @Pinned (4 cells): the BG STREAM
  parser has no equivalent of parse_bg_prefix — modifier sigils inside
  the stream body don't parse. Inconsistent with BG.

The @parallel-with-@local and @parallel-with-@multiowned diagnostics
fire correctly across all 3 boundaries — the canonical boundary-
crossing rules from sharing-capabilities.md are enforced.

Combined matrix: 203 active cells across 9 templates, 25 distinct
findings (1 leak + 21 mir-error + 3 unexpected-pass).

Per branch policy: matrix surfaces; fixes land elsewhere.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the remaining gaps in the borrow-escape rule so every borrow
producer feeds into one helper and every escape category fires the
same way at every shape (identifier, field-chain, index-chain).

Producer gaps closed (one helper):
- WITH-AS aliases (capabilities.rb 6 sites: BORROWED, RESTRICT, VIEW,
  SNAPSHOT, plain field, plain synced) now route through
  mark_borrow_binding_non_escaping! with `force: true`. Lock-bounded
  lifetime semantics share the helper with heap-payload borrow
  semantics. The `force:` keyword bypasses the Copy short-circuit —
  WITH-AS aliases need non_escaping even for Copy payloads since
  the lock can be released independently of payload Copy-ness.
- IF-AS and WHILE-AS now also set @og[name].kind = :borrowed for
  symmetry with MATCH-AS / FOR-EACH / pipeline-AS.

Enforcement gaps closed (chain-aware everywhere):
- ensure_owned_value!: container-store check extended via
  ifbind_source_root. New diag STORE_FIELD_OF_BORROW_INTO_CONTAINER
  for chain shapes; existing STORE_WITH_SCOPED_INTO_CONTAINER kept
  for single-level identifiers (better diagnostics).
- handle_assign_move: NEW check for plain assignment of a borrow
  chain. Walks ifbind_source_root; gates on (a) is_reassign — only
  Assignment nodes and BindExpr `:assign` mode, fresh decls are
  accepted, and (b) extracted-value type is non-Copy — so e.g.
  `v = y.value` where `.value` is Int64 stays accepted. New diag
  ASSIGN_BORROW_TO_OUTER_BINDING.
  This was MAL bug #5 (mal-bugs.md): the eval-loop's
  `ast = tco.tcoAst` previously slipped through.

Categories now uniformly enforced at the annotator level:

  ESCAPE / RETURN                  visit_ReturnNode            (chain)
  ESCAPE / container-store         ensure_owned_value!         (chain)
  ESCAPE / outer-binding-assign    handle_assign_move          (chain)
  ESCAPE / plain-assign chain      handle_assign_move          (chain) [NEW]
  BOUNDARY / fiber-capture         has_non_escaping_capture    (chain)
  GATED   / WITH RESTRICT/BORROWED BorrowChecker               (existing)

Defense layer: spec/borrow_escape_matrix_spec.rb is a combinatorial
(producer × category) regression suite. 16 cells covered today;
expandable as new patterns appear. Plain-assign repro:
transpile-tests/_xfail/borrow_escape_via_plain_assign.cht.

A separate MIR-level checker pass is intentionally NOT included —
the annotator IS the rejection gate, and the regression matrix
provides the safety net. A defensive MIR pass remains a future
follow-up if/when MIR-level rewrites generate synthetic escape
patterns the annotator can't see.

Verified: 4738 examples / 0 failures / 3 pending. Sorbet clean.
All transpile-tests/*.cht produce valid Zig. MAL still passes
75/75 (no false positives).

Files updated:
  - docs/agents/mal-bugs.md: bug #5 marked fixed
  - docs/postmortems/383_uaf_match_as_borrow_returned.md: universal
    coverage table

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three focused templates sharing _cleanup_dimensions.rb so adding a new
alloc kind propagates to all three; control-flow dimension is template-
specific (loop disruptors, error patterns, branch shapes).

Templates:
- loop_cleanup    (40 cells) — INV-2/INV-6: alloc inside loop bodies with
  break/continue/early-return/raise disruptors
- error_cleanup   (24 cells) — INV-9: alloc-cleanup on error paths
  (OR PASS / RAISE / DEFAULT × success/failure trigger)
- branch_cleanup  (48 cells) — INV-2: alloc-cleanup across IF/ELSE
  with then-only / else-only / both-same / both-diff shapes and
  optional early-return disruptors

Shared:
- _cleanup_dimensions.rb — ALLOC_KINDS, VALUE_DESTS, alloc/use
  helpers. Loaded but doesn't register a template.

docs/agents/formal-verification-bugs.md — catalogue of 10 active bugs
the matrix has surfaced so far across all templates, plus 10 emitter
workarounds tracked for removal when bugs are fixed. Each entry has
repro, symptom, and notes on which spec layer missed it.

Combined matrix (12 templates, 315 active cells, 30 :in_dev):
- 203 ok
- 6 leak
- 103 mir-error
- 3 unexpected-pass
= 112 distinct findings.

Many cleanup-correctness MIR-FAILs are likely related to bug #10
(`x = expr OR PASS` leaks for heap-returning fn), since the cleanup
templates use OR PASS as the caller wrapper. Once #10 is fixed
expect a cleaner baseline; remaining failures are real findings.

Per branch policy: matrix surfaces; fixes land elsewhere.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…actic position

Bug #10 in formal-verification-bugs.md (`result = expr OR PASS` leaks)
was only the tip. This matrix exercises every syntactic position OR can
appear in × action × inner outcome.

Cell schema: position × action × outcome × inner_t.

Positions tested:
- :assign_rhs      — `result = inner() OR …`
- :fn_arg          — `consume(inner() OR …)`
- :method_arg      — `outer.append(inner() OR …)`
- :return_expr     — `RETURN inner() OR …`
- :with_source     — `WITH EXCLUSIVE (inner() OR …) AS x { … }`
- :collection_lit  — `[inner() OR …]`

Actions: PASS, RAISE, <default>. Outcomes: inner succeeds vs raises.
Inner types: heap_list, heap_string. = 60 cells total.

Findings: 15 ok / 45 fail. The bug surface is much wider than
originally suspected — `OR <action>` cleanup pairing is broken across
most positions:

- All assign_rhs cells with inner=success leak (5 cells)
- All method_arg + heap_string + success leak (3 cells)
- All return_expr cells fail (LEAK or MIR-FAIL, 7 cells)
- All with_source cells MIR-FAIL universally (10 cells)
- Various others depending on action / outcome / type

Updated bug #10 in formal-verification-bugs.md with the broader surface.

Combined matrix now: 13 templates, 375 active cells, 30 :in_dev.

Per branch policy: matrix surfaces; fixes land elsewhere.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The lifetime check at function_analysis.rb#verify_return already
rejects borrow returns without COPY or RETURNS x:T — but its gate
used Type#copyable? / implicitly_copyable?, which treat strings,
slices, and any struct/union recursively containing them as Copy.
That answers "can I memcpy this value?" — true for slice headers
(ptr+len) — but conflates value-Copy with self-containment. The
bytes the slice points to live in someone else's heap; returning
the slice without a lifetime contract is a shallow-copy escape
that surfaces as UAF the moment the caller drops the source.

Repro: RETURN e.errMsg where e is a parameter compiled cleanly,
yielded a slice into caller-owned memory, and was relied on by
existing transpile-tests (77, 78) and a spec (caller_cleanup_spec).
Same hole exists for plain T[] slices and for structs/unions with
String fields.

Fix: new Type#self_contained?(resolver) in src/ast/type.rb. Stricter
than copyable?: returns false for string?, array?, heap?, sync/wrapper
types, containers, @indirect; recurses into structs and union variants
requiring all to be self-contained. function_analysis.rb#verify_return
gates on this instead of is_copyable.

The existing RETURNS x:T lifetime annotation IS the mechanism for
shallow-copy returns. No new MIR infrastructure needed (user asked
whether MIR-level dangling-pointer tracking was warranted — it is
not, the existing lifetime check handles it once the gate is correct).

Blast radius: 2 transpile-tests + 1 spec that did `RETURN x.name`
where x was a local. Real shallow-copy escapes; fixed by binding
`name = COPY x.name; RETURN name;`. The naive `RETURN COPY x.name`
form hits a separate lowering bug (UNHOISTED_ALLOC for DeepCopy in
non-Let-init position) — filed as task #27.

Matrix coverage: 4 new cells in spec/borrow_escape_matrix_spec.rb
under "function parameter (shallow-copy escape via param.field)".

Verified: 4738 examples / 0 failures / 3 pending. Sorbet clean.
All transpile-tests/*.cht produce valid Zig. MAL still passes
75/75 (already used COPY at every borrow-return site).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`lower_return` for catch_string_dupe_ret functions wraps every return
value in `MIR::DupeSlice.new(value, :heap)` to ensure both success
and error paths return heap-backed strings (consistent caller
cleanup contract).

That's correct for frame-allocated values (literals, slice-of-param,
frame concats) — the wrap promotes them to heap. But when `value`
is itself already heap-allocating (`MIR::DeepCopy` from `RETURN COPY
x.field`, `MIR::ConcatStr` from a heap-promoted concat, etc.), the
wrap NESTS the inner alloc inside DupeSlice. The MIR checker walks
the Let.init with allow_top: true and recurses with allow_top: false;
the nested DeepCopy lands in non-Let-init position and trips
UNHOISTED_ALLOC.

Surfaced by `RETURN COPY validUser.name` patterns in caller_cleanup
spec, transpile-tests/77, and transpile-tests/78 — those were
originally fixed with `name = COPY x.name; RETURN name;` workarounds
to sidestep this lowering bug. With this fix the cleaner form
works directly.

Fix: gate the DupeSlice wrap on `mir_allocates?(value)`. If the value
is already heap-allocating, hoist it as-is. Otherwise wrap in
DupeSlice as before.

Verified: 4742 examples / 0 failures / 3 pending. Sorbet clean. All
transpile-tests/*.cht produce valid Zig. MAL still passes 75/75.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
cuzzo added a commit that referenced this pull request May 16, 2026
Locals sourced from `x.type_info rescue nil` are provably nil|Type
post-#45, so the `Type.new(ti) if ti && !ti.is_a?(Type)` coercions
are dead and `if ti.is_a?(Type)` is a redundant nil-check. Collapsed:
escape_analysis per_fn_scan!(238), e2_loop_carry_names!(decl_ti,
outer_ti), e3_mark_carry_expr!(904,910); control_flow
_collect_share_moves; promotion_plan stamp_field_pre_cleanups!.
#52's 327/374 are .symbol.type (heterogeneous, = #47), left alone.
Gates green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
cuzzo added a commit that referenced this pull request May 16, 2026
User's model validated (target nil|Type|FunctionSignature; String/
Symbol slop; sigs -> T.any(Type,FunctionSignature)). Carrier
disambiguated: Literal#type is a separate token-kind field, no
blast radius. Real obstacle is semantic, not mechanical: the 11
is_a?(Type) sites double as a resolved-vs-unresolved gate, so
normalizing the seam changes which decls get processed. Documented
as the spec for a focused reviewed PR; not auto-run (gates-green !=
provably-correct for semantic change). #48-#51,#53,#57,#59 same.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
cuzzo added a commit that referenced this pull request May 17, 2026
Locals sourced from `x.type_info rescue nil` are provably nil|Type
post-#45, so the `Type.new(ti) if ti && !ti.is_a?(Type)` coercions
are dead and `if ti.is_a?(Type)` is a redundant nil-check. Collapsed:
escape_analysis per_fn_scan!(238), e2_loop_carry_names!(decl_ti,
outer_ti), e3_mark_carry_expr!(904,910); control_flow
_collect_share_moves; promotion_plan stamp_field_pre_cleanups!.
#52's 327/374 are .symbol.type (heterogeneous, = #47), left alone.
Gates green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
cuzzo added a commit that referenced this pull request May 17, 2026
User's model validated (target nil|Type|FunctionSignature; String/
Symbol slop; sigs -> T.any(Type,FunctionSignature)). Carrier
disambiguated: Literal#type is a separate token-kind field, no
blast radius. Real obstacle is semantic, not mechanical: the 11
is_a?(Type) sites double as a resolved-vs-unresolved gate, so
normalizing the seam changes which decls get processed. Documented
as the spec for a focused reviewed PR; not auto-run (gates-green !=
provably-correct for semantic change). #48-#51,#53,#57,#59 same.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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.

2 participants