Skip to content

feat(d5): D5.2 — T1 fresh-returning factories become acquire sites#116

Merged
PhysShell merged 5 commits into
mainfrom
claude/agenda-2rufsj
Jun 26, 2026
Merged

feat(d5): D5.2 — T1 fresh-returning factories become acquire sites#116
PhysShell merged 5 commits into
mainfrom
claude/agenda-2rufsj

Conversation

@PhysShell

@PhysShell PhysShell commented Jun 26, 2026

Copy link
Copy Markdown
Owner

P-005 D5.2 — T1 return-value door

A factory leakvar r = MakeReader(); /* never disposed */ — was silently lost before this slice. T1 closes it: a fresh-returning call becomes an acquire site, so the existing leak / double-release / use-after-release checks apply to the result.

What ships

  1. Return-kind inference_build_skeletons now derives a method's owned-return kind via _infer_return_skeleton:
    • a body that acquires a local and returns it → fresh (a factory)
    • a single returned local that is the result of a first-party callforward-return, which the D5.0 solver propagates (factory-of-factory)
  2. Caller-side wiring — in _lower_flow, a call op that binds a result whose callee summary returns fresh is also lowered to an acquire of that local. The MOS is threaded into _lower_flow exactly as _lower_fn_params already receives it. The args' effects (if any) are still applied by the emitted Call; the new acquire models the return.

new T() is already an acquire op from the extractor; this slice adds the first-party factory-method door.

Precision-first (the floor stays own-only 0)

  • A returned parameter is never fresh — that is wrap/alias (T4 / D5.4). No false acquire of the result, no consume of the arg.
  • A non-fresh / unknown return makes no claim → a result is never falsely owned.

Tests (139 → 144 bridge checks)

  • factory-result never disposed → OWN001 @ the call site
  • factory result disposed → clean
  • use-after-dispose of a factory result → OWN002
  • fresh propagates through a forward-return factory-of-factory → caller OWN001
  • returning a parameter is not fresh → silent (precision guard)

Full suite green (run_tests.py exit 0), ruff + mypy --strict clean.

Out of scope (noted in the design note)

The out/ref-owned parameter — the other fresh source — needs extractor-side recognition of an out-assignment as a fresh acquire; it rides into a later T1 slice before async.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Fmwk5ruExKy5fQ77CJ2NtF


Generated by Claude Code

Summary by CodeRabbit

  • New Features
    • Expanded inter-procedural ownership inference to detect owned-return “fresh” results from factory-like calls and apply ownership at the call site, including factory-of-factory propagation.
  • Bug Fixes
    • Avoid incorrect ownership claims for non-fresh, mixed-origin, or ambiguous returns.
    • Ensured return parameters and non-uniform return paths never trigger fresh ownership.
  • Tests
    • Added/extended P-005 D5.2 (T1) coverage for leak/cleanup behavior, double-release/use-after-dispose, and control-flow precision cases.
  • Documentation
    • Updated the D5.2 roadmap entry with clarified classification and lowering behavior.

Infer a method's owned-return kind in _build_skeletons: a body that acquires a
local and returns it is fresh (a factory); a single returned local that is the
result of a first-party call is a forward-return the solver propagates. Caller-
side, a call op that binds a result whose callee summary returns fresh is also
lowered to an acquire of that local, so the existing leak / double-release /
use-after-release checks apply at the call site -- the factory-leak class, lost
before D5.2.

Precision-first: a returned parameter is never fresh (that is wrap/alias,
T4/D5.4), and a non-fresh/unknown return makes no claim, so a result is never
falsely owned. The out/ref-owned door is noted as a later T1 slice.

Tests: factory-result leak (OWN001 at the call), disposed-clean, use-after-
dispose (OWN002), forward-return propagation, param-return precision guard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fmwk5ruExKy5fQ77CJ2NtF
@coderabbitai

coderabbitai Bot commented Jun 26, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: d46c331e-e332-4031-a28c-ad9dd7630e5c

📥 Commits

Reviewing files that changed from the base of the PR and between f85ecea and b68c198.

📒 Files selected for processing (2)
  • ownlang/ownir.py
  • tests/test_ownir.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • tests/test_ownir.py
  • ownlang/ownir.py

📝 Walkthrough

Walkthrough

The D5.2 slice adds return-ownership inference, lowers fresh call results to call-site acquires, and expands the roadmap note and tests for forwarding, disposal, use-after-release, and parameter-return precision.

Changes

Fresh-return ownership transfer

Layer / File(s) Summary
Return skeleton inference
ownlang/ownir.py
Adds return-skeleton imports and helpers, updates _build_skeletons, and stores inferred return kinds in MethodSkeleton.
Fresh call-result lowering
ownlang/ownir.py
Threads solved method summaries through lowering and mints an acquire for call results when the callee returns fresh.
Roadmap note and tests
docs/notes/d5-ownership-transfer.md, tests/test_ownir.py
Expands the D5.2 note and adds tests for fresh-return leaks, disposal, use-after-release, forwarding, parameter-return precision, and branch lowering.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • PhysShell/Own.NET#111: Introduces the return-kind machinery that this slice consumes in ownlang/ownir.py.
  • PhysShell/Own.NET#112: Extends the same OwnIR skeleton/lowering pipeline around interprocedural ownership transfer.

Poem

A bunny bounced through ownership snow,
Where fresh returns now sparkle and flow.
Calls hop to acquires,
The tests light the fires,
And leaks get a gentle “no-no.” 🐇

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: fresh-returning factories becoming acquire sites in D5.2 T1.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/agenda-2rufsj

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

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0bf537b3ee

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread ownlang/ownir.py
localmap: dict[str, str],
released_vars: set[str]) -> list[Stmt]:
released_vars: set[str],
mos: dict[str, Any] | None = None) -> list[Stmt]:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Thread MOS into nested flow lowering

When a fresh-returning factory call is inside an if or while body, the recursive _lower_flow calls still omit this new mos argument, so those nested invocations run with mos=None and skip the D5.2 acquire for call ... result. I checked an if branch containing {"op":"call","callee":"make","result":"r"} where make returns a freshly acquired local, and check_facts returns no OWN001, while the same top-level call is reported; pass mos through the recursive branch/loop calls so factory leaks in control-flow bodies are not silently missed.

Useful? React with 👍 / 👎.

…while are caught (Codex P2)

The recursive _lower_flow calls for if/then/else and while bodies dropped the
new mos argument, so a fresh-returning factory call inside control flow ran with
mos=None and silently skipped the D5.2 acquire -- a factory leak in a branch/loop
went unreported. Pass mos through all three recursive calls.

Adds a regression test: a fresh factory call in an if branch whose result is
never disposed must leak OWN001 at the call site, like the top-level case.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fmwk5ruExKy5fQ77CJ2NtF

@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 `@ownlang/ownir.py`:
- Around line 974-984: The freshness inference in ownir.py’s return
classification is too permissive because it treats any returned variable found
in acquire tracking as fresh even when that local can also come from a call
result on another path. Tighten the logic in the returned/acquired check before
returning ReturnSkeleton("fresh") so it only marks fresh when the returned
locals are exclusively acquired and not mixed with _call_result_callees-derived
values or other origins; use the existing returned, acquired, and
_call_result_callees helpers around the ReturnSkeleton branch to make the
decision path-sensitive.
- Around line 1253-1265: Pass the solved summary map through recursive lowering
in `_lower_flow()` so fresh-result handling works in nested bodies. The `if` and
`while` branches currently recurse without `mos`, which means `call` operations
with a `result` inside nested flows never become `acquire` statements. Update
the recursive calls in `_lower_flow()` to forward `mos` consistently, and verify
the fresh-result logic still applies for nested `then`/`else` and loop bodies.
🪄 Autofix (Beta)

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: CHILL

Plan: Pro Plus

Run ID: 765d8f3e-fad8-4e56-b06d-a364327e8584

📥 Commits

Reviewing files that changed from the base of the PR and between 61b278f and 0bf537b.

📒 Files selected for processing (3)
  • docs/notes/d5-ownership-transfer.md
  • ownlang/ownir.py
  • tests/test_ownir.py

Comment thread ownlang/ownir.py
Comment thread ownlang/ownir.py
…it Major)

_infer_return_skeleton classified a returned local as fresh whenever it appeared
in any acquire, even if the same local is also a call result on another path
(if c: x = acquire() else: x = other()). That would make a caller acquire a value
it does not own on the non-acquire path, fabricating OWN001/OWN002 there. Require
every returned local to be acquired AND not also a call result before claiming
fresh; otherwise degrade to no-claim (precision-first).

Adds a regression test: a method that acquires x then overwrites it with a non-
owned call result and returns it is not fresh, so a caller's dropped result is
silent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fmwk5ruExKy5fQ77CJ2NtF

Copy link
Copy Markdown
Owner Author

Both Major findings from the earlier reviews are addressed on the latest commit f85ecea:

  • mos not threaded into nested _lower_flow (also flagged by Codex) → fixed in 0b45698; factory leaks inside if/while are now caught (regression test added).
  • fresh inferred from a mixed-origin return local → fixed in f85ecea (matches the proposed precision-first diff); a returned local must be acquired and not also a call result before it is claimed fresh, else it degrades to no-claim (regression test added).

CI is fully green on f85ecea and the branch is mergeable. CodeRabbit's last incremental pass on this commit was rate-limited, so re-requesting a fresh review on the final state:

@coderabbitai review
@codex review


Generated by Claude Code

@coderabbitai

coderabbitai Bot commented Jun 26, 2026

Copy link
Copy Markdown

@PhysShell Thanks for the update — I’ll run a fresh review on the latest state of #116.

✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f85ecea1cd

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread ownlang/ownir.py
Comment on lines +983 to +984
if all(v in acquired and v not in param_names and v not in call_results
for v in returned):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Require every return path to be fresh

When a method has one branch returning an acquired local and another branch returning no owned value (for example a null/default result represented as return without var), _collect_vars only records the acquired branch, so this condition marks the whole method as fresh. A caller that drops that result is then reported as OWN001 even though the callee can return a non-owned value on another normal path; this violates the precision-first rule that fresh means every returned value is newly owned.

Useful? React with 👍 / 👎.

Comment thread ownlang/ownir.py
and summ is not None and getattr(summ, "returns", None) == "fresh"):
handle = f"loc_{loc[0]}"
loc[0] += 1
localmap[result] = handle

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve factory result locals across branch merges

When a fresh factory result is assigned inside an if/else and then disposed after the branch (e.g. both branches assign r = make(); followed by release r), this assignment leaves localmap pointing at a synthetic Let declared inside one branch. The later release is emitted outside that branch scope, so CFG lowering reports OWN030 undefined name and check_facts raises OwnIRError instead of accepting a balanced factory result.

Useful? React with 👍 / 👎.

claude added 2 commits June 26, 2026 12:38
_infer_return_skeleton only collected returns carrying a var, so a method that
returns an acquired local on one branch but a bare return (return null / non-
owned) on another was still marked fresh -- a caller dropping its result was
charged a bogus OWN001 on the null path. Add _has_bare_return: if any return
path yields a non-owned value, make no fresh/forward claim (precision-first).

Regression test: a method with a return-null branch is not fresh, so a caller's
dropped result is silent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fmwk5ruExKy5fQ77CJ2NtF
…l regression

Codex flagged that a local acquired in both if-branches and released after the
merge crashes (OWN030 -> OwnIRError). Confirmed pre-existing: it reproduces with
a plain acquire, no fresh/factory path, so it is a bridge flat-localmap branch-
scope limitation, not a D5.2 regression. Per the decision, land D5.2 as-is and
track the bridge fix as its own slice (make acquire lowering branch-aware / hoist
synthetic handles to the merge scope) rather than soft-skipping OWN030, which
would mask real lowering drift.

Records the limitation in the D5 note and adds an xfail-style lock
(branch_merge) asserting the current raise, to flip to a clean-balanced-release
assertion once the bridge fix lands.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fmwk5ruExKy5fQ77CJ2NtF

Copy link
Copy Markdown
Owner Author

Both Codex findings addressed:

1. "Require every return path to be fresh" (line 984) — valid, fixed in b68c198. _infer_return_skeleton now detects a bare/return null path via _has_bare_return and declines the fresh/forward claim when any return path yields a non-owned value, so a caller dropping such a result is no longer charged a bogus OWN001. Regression test added.

2. "Preserve factory result locals across branch merges" (line 1348) — confirmed real, but pre-existing and not a D5.2 regression: the same OWN030 → OwnIRError reproduces with a plain acquire assigned in both if branches and released after the merge — no fresh/factory path involved. Root cause is the bridge's flat localmap emitting each synthetic Let inside its branch block, so a post-merge release references an out-of-scope handle.

Decision (per maintainer): land D5.2 as-is and track the bridge fix as its own slice — make acquire lowering branch-aware (hoist/declare synthetic handles at the merge scope). We deliberately are not soft-skipping OWN030, since the strict map-or-raise invariant is load-bearing and skipping would mask genuine lowering drift. The limitation is recorded in docs/notes/d5-ownership-transfer.md and locked by an xfail-style regression (branch_merge) in 4a879cd that asserts the current raise and will flip to a clean-balanced-release assertion once the bridge fix lands.

Thanks for both catches — #1 was a genuine precision bug.


Generated by Claude Code

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