Skip to content

P-005 D5: ownership-transfer design + D5.0 summary solver (infra)#111

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

P-005 D5: ownership-transfer design + D5.0 summary solver (infra)#111
PhysShell merged 5 commits into
mainfrom
claude/agenda-2rufsj

Conversation

@PhysShell

@PhysShell PhysShell commented Jun 26, 2026

Copy link
Copy Markdown
Owner

What

The design for P-005 D5 (interprocedural IDisposable ownership transfer)
plus its first code — the D5.0 summary solver. Design + infra in one pile, as
agreed; no behaviour change (no findings wired yet).

Design — docs/notes/d5-ownership-transfer.md (+ P-005 pointer)

Worked out across three literature/tooling reviews (Checker Framework RLC / RLC#,
CA2000, ReSharper, Move, Polonius/Flowistry). Headlines:

  • D5 is a summary-contract problem, not a borrow-checker-core one: an
    extractor-side Method Ownership Summary (MOS) pass feeding the core's existing
    consume/borrow/owned-return vocabulary. Minimal new core code.
  • Schema keeps escape and transfer on separate axes (escape ≠ ownership
    transfer); transfer is three-valued (must/may/no + unknown); returns add
    aliasOf:<i> for the wrapper pattern.
  • Obligation-identity model (resolves open question 1 → Variant B): obligations
    live on a resource id (RID, Open|Released); handles are access paths; an alias
    set is the handles sharing one RID, and disposing any owning member discharges
    it. Default 1:1 (today's behaviour); N:1 only on a proven alias. T4a (factory
    returns wrapper) and T4b (ctor adopts into owning field) collapse to one
    primitive, alias_join; the leak check becomes per-RID. Hard v1 constraints:
    aliasOf is must-only, single-source, owning-only, no whole-program heap-alias.
    Dapper DbWrappedReader worked as a mix (aliasOf:reader + cmd adopted).
  • Precision-first default (optimistic on unknown → silent), with a strict mode
    and an OWN05x advisory for may/unknown computed regardless.
  • Tiered truth (A first-party fixpoint → B BCL leaveOpen table → C annotations →
    D heuristic), slices D5.0–D5.5 (a leaveOpen slice folded into the first live
    milestone; out/ref before async), and a prior-art reading list.

Code — D5.0 ownlang/ownership.py (+ tests)

The MOS types and a depth-capped bottom-up solver over the first-party call
graph. Pure data + algorithm, locally testable, no extractor, no findings:

  • Transfer lattice {no, must, may, unknown} with an associative join (unknown
    absorbs; transfer-path ⊔ keep-path → may).
  • Per-method skeleton → resolved summary: direct dispose/adopt/return → must,
    borrow → no, forward → the callee's summary, extern / past-cap → unknown,
    recursion edge → no (cycle broken). must is claimed only when ownership
    leaves the caller on every recorded path
    (precision-first).
  • Depth cap 3 (CA2000's max_interprocedural_method_call_chain default),
    logged via solve_with_log — no silent truncation.
  • Return kinds fresh / aliasOf:i / aliased / forward / none; to_dict emits the
    detached summaries[] serialization.

tests/test_ownership.py33 checks (wired into run_tests.py): the join
lattice, direct/forward/partial transfer, extern boundary, depth-cap logging,
mutual + self recursion convergence, return-kind resolution, serialization. Full
suite stays green.

Not in scope (next slices)

D5.1 lowers summaries into the core consume/borrow/acquire vocabulary (+ a
leaveOpen Tier-B slice); D5.4 adds the RID/alias_join obligation-identity
model in a three-commit cadence. This PR is design + the locally-testable solver
infra only.

🤖 Generated with Claude Code


Generated by Claude Code

Summary by CodeRabbit

  • New Features
    • Added an interprocedural ownership-summary solver that computes per-parameter transfer states and return-ownedness across call chains.
    • Added summary serialization and a logging mode that records depth-cap truncation events.
  • Documentation
    • Added a design note for interprocedural ownership transfer (summary-contract model, reporting tiers, and incremental rollout plan).
    • Updated the disposable-ownership proposal with clearer current-scope and a pointer to the full D5 plan.
  • Tests
    • Expanded unit tests for joins, forwarding, recursion/SCC convergence, extern handling, depth caps, return-kind resolution, and serialization.
    • Updated the test runner to include the new ownership suite.

claude added 3 commits June 26, 2026 07:21
Lock in the worked-out design for D5 as docs/notes/d5-ownership-transfer.md
and point P-005 at it. Key decisions, sharpened by a literature/tooling
review (RLC/RLC#, CA2000, ReSharper, Move, Polonius/Flowistry):

- D5 is a summary-contract problem, not a borrow-checker-core one: an
  extractor-side Method Ownership Summary (MOS) pass feeding the core's
  EXISTING consume/borrow/owned-return vocabulary. Minimal new core code.
- Schema keeps escape and transfer on SEPARATE axes (escape != ownership
  transfer); transfer is three-valued must/may/no(+unknown); returns add
  aliasOf:<i> for the MustCallAlias wrapper pattern (Dapper DbWrappedReader).
- Tiered truth: A first-party SCC fixpoint (cap 3, logged), B curated BCL
  table (leaveOpen is the crown jewel), C annotations, D heuristic fallback.
- Precision-first default (optimistic on unknown -> silent), with a strict
  mode and an OWN05x advisory for may/unknown computed regardless of mode.
- Slices D5.0..D5.5 with a leaveOpen Tier-B slice folded into the first
  live-behavior milestone; out/ref-owned before IAsyncDisposable.
- Prior-art reading list so we don't reinvent blindly.

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

A third literature/tooling pass (Checker Framework RLC, RLC#, Clang ARC,
ReSharper, Roslyn CA2000) converges with no reservations on the
RID/alias-set model for the wrapper case. Lock it in:

- New section 11: obligations live on a resource id (RID, state
  Open|Released), handles are access paths; an alias set is the handles
  sharing one RID, and disposing any owning member discharges it. Default
  1:1 (today's behaviour); N:1 only on a *proven* alias.
- T4a (factory returns wrapper) and T4b (ctor adopts into owning field)
  collapse to one primitive: alias_join — a new owning handle joins the
  arg's RID. Leak check becomes per-RID, not per-binding.
- Hard v1 constraints: aliasOf is must-only (unproven -> silent); single
  source RID + single new handle (>1 owning field can't form the simple
  relation); owning-only (views stay in borrow machinery); no
  whole-program heap-alias — verified by forward-and-return or
  store-in-owning-field, the two RLC shapes.
- Dapper DbWrappedReader worked as a mix: aliasOf:reader + cmd adopted as
  an ordinary owning field. Polly Bulkhead is the pure T4b form.
- D5.4 lands as a three-commit cadence (no-op RID refactor -> alias_join
  lowering -> extractor aliasOf emission), de-risked behind the green
  D5.0-D5.3 corpus. Reading anchors: Checker Framework manual 8.5, RLC
  paper 4, RLC# resource-alias layer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fmwk5ruExKy5fQ77CJ2NtF
First code for P-005 D5, per docs/notes/d5-ownership-transfer.md. Pure
data + algorithm; NO findings wired, no behaviour change (D5.1+ lowers
summaries into the core consume/borrow/acquire vocabulary).

ownlang/ownership.py: the Method Ownership Summary (MOS) types and a
depth-capped bottom-up solver over the first-party call graph.
- Transfer lattice {no, must, may, unknown} with an associative join
  (unknown absorbs; a transfer-path joined with a keep-path -> may).
- Per-method skeleton (extractor-facing input) -> resolved summary:
  direct dispose/adopt/return -> must, borrow -> no, forward -> the
  callee's summary, extern or past-cap -> unknown, recursion edge -> no
  (broken at the cycle). Precision-first: must is claimed only when
  ownership leaves the caller on every recorded path.
- Depth cap = 3 (CA2000's max_interprocedural_method_call_chain default),
  logged via solve_with_log (no silent truncation).
- Return kinds fresh / aliasOf:i / aliased / forward / none, with
  aliasOf reserved for the D5.4 obligation-identity model.
- to_dict emits the detached summaries[] serialization.

tests/test_ownership.py (33 checks, wired into run_tests.py): the join
lattice, direct/forward/partial transfer, extern boundary, depth-cap
logging, mutual + self recursion convergence, return-kind resolution,
and serialization. Full suite stays green.

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: d0c0f31a-d5e6-4f54-837d-1a9dbb602160

📥 Commits

Reviewing files that changed from the base of the PR and between 4543fff and 072d7e2.

📒 Files selected for processing (3)
  • docs/notes/d5-ownership-transfer.md
  • ownlang/ownership.py
  • tests/test_ownership.py
✅ Files skipped from review due to trivial changes (1)
  • docs/notes/d5-ownership-transfer.md
🚧 Files skipped from review as they are similar to previous changes (2)
  • ownlang/ownership.py
  • tests/test_ownership.py

📝 Walkthrough

Walkthrough

Adds D5 ownership-transfer design notes, implements an interprocedural ownership summary solver, and extends tests and the test runner to cover the new solver.

Changes

Interprocedural ownership summaries

Layer / File(s) Summary
D5 framing and proposal link
docs/notes/d5-ownership-transfer.md, docs/proposals/P-005-idisposable-ownership.md
D5 is introduced as interprocedural ownership transfer, and the proposal note now points to the new design note and current heuristic bounds.
MOS contract and lowering rules
docs/notes/d5-ownership-transfer.md
The note defines MOS parameter transfer and escape fields, return ownedness states, transfer-direction mappings, source precedence, reporting behavior, and OwnIR serialization and lowering.
Fixpoint, scope, and identity model
docs/notes/d5-ownership-transfer.md
The note describes the SCC-based summary pass, capped depth logging, implementation slices, testing strategy, scope limits, RID-based obligation identity, and prior art.
Ownership summary model
ownlang/ownership.py
ownlang/ownership.py introduces the depth cap, transfer lattice, skeleton dataclasses, summary dataclasses, and MethodSummary.to_dict().
Resolution engine and public API
ownlang/ownership.py
Parameter and return resolution handle forward edges, recursion, depth caps, extern and missing cases, and expose solve_with_log plus solve.
Ownership tests and harness wiring
tests/run_tests.py, tests/test_ownership.py
The new test suite covers lattice joins, direct and interprocedural transfer cases, recursion, returns, serialization, and runner integration.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

(\_/)
I hop through summaries, neat and bright,
Transfer, borrow, and return in sight.
Deep call chains hum, the cap goes click,
My carrot bell rings: “review me quick!” 🥕

🚥 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 summarizes the design note plus the D5.0 summary solver infrastructure added in this PR.
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: 27d9166c38

ℹ️ 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/ownership.py Outdated
Comment on lines +154 to +156
if skel is None or i < 0 or i >= len(skel.params):
return Transfer.UNKNOWN
p = skel.params[i]

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 Resolve forwarded args by ParamSkeleton.index

PathAction.arg is the callee's parameter index, but this lookup treats it as a dense tuple offset. When the skeleton only contains disposable/interesting parameters or otherwise has a non-zero first ParamSkeleton.index (for example a wrapper Create(..., reader) at index 1), forwarding to arg=1 satisfies this out-of-range check and is downgraded to unknown, so first-party consumes/adoptions after earlier unmodeled parameters are silently lost instead of resolving through the summary.

Useful? React with 👍 / 👎.

Comment thread ownlang/ownership.py Outdated
return "unknown"
if r.callee in stack:
return "unknown"
return _resolve_return(r.callee, depth + 1, stack | {r.callee}, cap, sk, capped)

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 Remap aliasOf through forward-return calls

_resolve_return returns the callee's aliasOf:<i> unchanged, but <i> is in the callee's parameter space. For a forwarding wrapper like Outer(a, b) => Inner(b) where Inner returns aliasOf:0, this summarizes Outer as aliasOf:0 instead of aliasOf:1; when lowered, the wrong argument RID would be joined/discharged while the actually adopted argument is missed. The forward-return summary needs to carry and apply the call's argument mapping before propagating aliasOf.

Useful? React with 👍 / 👎.

… (Codex)

Two P2s in the D5.0 solver, both real:

- _resolve_param indexed skel.params[i] as a tuple offset, but i is the
  callee's logical parameter index (ParamSkeleton.index). A sparse
  skeleton (e.g. a wrapper Create(cmd, reader) listing only reader at
  index 1) made a forward to arg=1 fall off the end and degrade to
  unknown, silently losing first-party consumes/adoptions after earlier
  unmodeled params. Now resolved by .index; solve_with_log passes the
  logical index too.
- _resolve_return propagated a callee's aliasOf:<i> verbatim, but <i> is
  in the callee's param space; for Outer(a,b) => Inner(b) where Inner
  returns aliasOf:0, Outer was summarized aliasOf:0 instead of aliasOf:1,
  which would join/discharge the wrong RID at lowering. Remapping needs
  the call's arg mapping (arrives with the obligation-identity model in
  D5.4); until then degrade forward-return-of-aliasOf to unknown
  (precision-safe). fresh/aliased/none still propagate.

Two regression tests added (35/35).

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

🧹 Nitpick comments (1)
tests/test_ownership.py (1)

149-155: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a forwarded-aliasOf remapping regression.

The return tests only cover forward -> fresh, so they won't catch the wrapper case where a callee resolves to aliasOf:<j> and the caller passes its own parameter in a different slot. A small reordered-argument case here would lock down the intended caller-local alias contract.

Example test to add
     s = solve([
         _m("Make", ret=ReturnSkeleton("forward", callee="Inner")),
         _m("Inner", ret=ReturnSkeleton("fresh")),
     ])
     expect(s["Make"].returns == "fresh", "forward-return propagates fresh")
+
+    s = solve([
+        _m("Wrap",
+           _p(0, PathAction("forward", "Inner", 1)),
+           ret=ReturnSkeleton("forward", callee="Inner")),
+        _m("Inner",
+           _p(0),
+           _p(1, PathAction("return")),
+           ret=ReturnSkeleton("aliasOf", arg=1)),
+    ])
+    expect(s["Wrap"].returns == "aliasOf:0",
+           "forward-return remaps aliasOf to caller param")
🤖 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 `@tests/test_ownership.py` around lines 149 - 155, Add a regression test in the
ownership return suite to cover the forwarded alias remapping case, since the
existing forward-return check in solve only verifies forward -> fresh. Extend
the test around solve and ReturnSkeleton so a caller using forward to an aliasOf
result from the callee still preserves the caller-local alias contract when
arguments are reordered, and assert the caller’s returns reflect the remapped
alias rather than the callee’s original slot.
🤖 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 `@docs/notes/d5-ownership-transfer.md`:
- Around line 74-93: The MOS schema in this section does not match the solver
output from MethodSummary.to_dict(), because it omits the explicit none return
state and treats via as part of the canonical summaries contract even though
D5.0 does not serialize it. Update the returnsOwned definition in this doc to
include none as a distinct return state alongside fresh, aliased, aliasOf:<i>,
and unknown, and revise the guidance around via to mark it as
optional/debug-only rather than canonical. Use MethodSummary.to_dict() and
ownlang/ownership.py as the anchors for the schema text so readers can map the
documented states to the actual serialized form.

In `@ownlang/ownership.py`:
- Around line 94-103: The `ReturnSkeleton`/`_resolve_return()` forwarding path
is preserving the callee’s `aliasOf:<i>` identity instead of the caller-local
argument identity, which breaks wrapper forwarding. Update `ReturnSkeleton` and
the forward-handling logic in `_resolve_return()` so forwarded returns can
retain or translate the call-site argument mapping from the wrapper, and if that
mapping is unavailable, conservatively downgrade forwarded `aliasOf` returns
rather than emitting the callee-local index.
- Around line 218-220: The skeleton aggregation in ownership.py currently builds
sk with a dict comprehension that silently overwrites duplicate method keys, so
update the logic around the skeletons-to-sk map to detect repeated s.key values
and fail fast instead of keeping the last one. Use the existing skeletons/sk
setup in the method that prepares MethodSummary data, and add an explicit
duplicate check before any later call-graph or summary processing so input order
cannot change the result.
- Around line 193-208: The _resolve_return logic in ownership.py is collapsing
every unrecognized ReturnSkeleton.kind into "none", which can hide owned-return
information. Update the kind dispatch so only the explicit "none" case returns
"none"; keep all other unknown kinds as "unknown" (or raise) in the same
_resolve_return function alongside the existing fresh, aliasOf, aliased, and
forward branches.

---

Nitpick comments:
In `@tests/test_ownership.py`:
- Around line 149-155: Add a regression test in the ownership return suite to
cover the forwarded alias remapping case, since the existing forward-return
check in solve only verifies forward -> fresh. Extend the test around solve and
ReturnSkeleton so a caller using forward to an aliasOf result from the callee
still preserves the caller-local alias contract when arguments are reordered,
and assert the caller’s returns reflect the remapped alias rather than the
callee’s original slot.
🪄 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: 4b2f2af7-993e-4a33-8a71-3a846d48cb9f

📥 Commits

Reviewing files that changed from the base of the PR and between 0a68216 and 27d9166.

📒 Files selected for processing (5)
  • docs/notes/d5-ownership-transfer.md
  • docs/proposals/P-005-idisposable-ownership.md
  • ownlang/ownership.py
  • tests/run_tests.py
  • tests/test_ownership.py

Comment thread docs/notes/d5-ownership-transfer.md Outdated
Comment thread ownlang/ownership.py
Comment thread ownlang/ownership.py Outdated
Comment thread ownlang/ownership.py Outdated
…a (CodeRabbit)

Three Major review items on the D5.0 solver:

- solve_with_log: reject duplicate MethodSkeleton keys (raise ValueError)
  instead of silently keeping the last via a dict comprehension. Key
  collision-freedom is an open design question (note s10 q2); the silent
  overwrite would make summaries input-order dependent.
- _resolve_return: an unrecognised ReturnSkeleton.kind now fails closed to
  'unknown' rather than collapsing to 'none' (which would erase
  owned-return info); only the explicit 'none' kind returns 'none'.
- d5-ownership-transfer.md: align the MOS schema with the solver — add the
  explicit 'none' return state (to_dict emits it; distinct from unknown),
  and mark 'via' as debug-only, not part of the canonical summaries[]
  contract and not serialized by D5.0.

Two regression tests added (dup-key raises, unknown return kind ->
unknown); 37/37, full suite green. The paired nitpick (a test expecting
forward-return to *remap* aliasOf to the caller slot) is intentionally
skipped: D5.0 deliberately degrades forwarded aliasOf to unknown — the
arg-mapping needed to remap arrives with the obligation-identity model in
D5.4 — and a degrade-to-unknown test already pins that.

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