Skip to content

Commit c6e8a3c

Browse files
authored
Merge pull request #418 from PhysShell/research/n1-f0
docs(evidence): N1-F0 — KILL-N1 at F0-B (may-throw model, not ownership)
2 parents 38fb952 + ac094bd commit c6e8a3c

11 files changed

Lines changed: 883 additions & 0 deletions

File tree

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
# N1-F0 — ownership feasibility gate — pre-registration
2+
3+
Written 2026-10-20, 21:51 +05, before any F0 run. Authority: owner decision
4+
`ACCEPT-N1-COMPETITOR-GATE / GO-N1-OWNERSHIP-FEASIBILITY / NO-PRODUCTION`, on #417.
5+
6+
- **Budget.** 0.25 agent-day for F0-A, F0-B and F0-C together.
7+
- **Outcome.** One verdict, `PASS-F0` or `KILL-N1`. `PASS-F0` does not authorise production work or a PoC.
8+
- **Rules of the work.** No production, OwnIR, CI or golden change. No third-party build and no package download (D13).
9+
10+
## F0-A — finish the competitor check
11+
12+
**Run.** The four reproductions of #417 (`n1-competitor-gate-2026-10/repro`, stubs as a separate assembly), under:
13+
14+
| K | CA2000 settings |
15+
|---|---|
16+
| A1 | `AllPaths`, `interprocedural_analysis_kind = None` |
17+
| A2 | `AllPaths`, `NonContextSensitive` |
18+
| A3 | `AllPaths`, `ContextSensitive` |
19+
| A4–A6 | A1–A3 plus `dispose_ownership_transfer_at_constructor = true` and `…_at_method_call = true` |
20+
21+
plus K1–K5 of #417 again, so that every configuration uses the new collector.
22+
23+
**Collector.** Every diagnostic whose location is inside the buggy **method** of each pair, from the first line of
24+
its declaration to its closing brace, not only on the `ACQ` line. A diagnostic is **disposal-related** if its rule
25+
is one of the following, or its message contains `dispos` (case-insensitive):
26+
27+
- CA2000, CA1001, CA2213, CA2215, CA2216;
28+
- IDISP001–IDISP026;
29+
- S2930, S2931, S3966, S4487.
30+
31+
**Verdict per pair.**
32+
33+
- **`CAUGHT`:** at least one disposal-related diagnostic in the buggy method names the leaked resource (its
34+
acquisition expression or its variable) in any configuration.
35+
- **`ADJACENT`:** disposal-related diagnostics that name another object. They are listed and do not count.
36+
- **`MISSED`:** otherwise.
37+
38+
**F0-A kill.** Fewer than 2 pairs `MISSED` → `KILL-N1`. The other parts are then not scored.
39+
40+
## F0-B — ownership oracle
41+
42+
**Oracle (fixed now).** A call result `r` acquired in method `M` of type `T` is **owned by `M`** only when all of
43+
the following hold:
44+
45+
1. **Hand-over target.** On the success path, `M` hands `r` to one of:
46+
- (a) an instance field `f` of `T`;
47+
- (b) an element of a collection field `f` of `T` (indexer store, `Add`, `TryAdd`);
48+
- (c) a constructor argument of an in-source type `W` whose constructor stores it in a field `g` of `W`, where
49+
the `W` instance is returned or stored as in (a)/(b).
50+
2. **The repository itself disposes that target.** The same type (`T`, or `W` for (c)) contains a
51+
`Dispose`/`DisposeAsync`/`Close`/`CloseAsync` call whose receiver is:
52+
- `f` or `g`;
53+
- a local obtained from `f` by `TryRemove(…, out x)`, `Remove(…, out x)`, an indexer read, or `foreach` over
54+
`f` / `f.Values`;
55+
- or `using` / `await using` of one of those.
56+
3. **Not a borrow.** The acquisition call is not in the borrow table: `IServiceProvider.GetService`,
57+
`GetRequiredService`, `GetServices`, `GetKeyedService*`, `ActivatorUtilities.GetServiceOrCreateInstance`. The
58+
table only suppresses; it never creates ownership.
59+
60+
Ownership is **never** inferred from the result type being `IDisposable`/`IAsyncDisposable`, from a `Create*` name,
61+
or from `await`. The result type matters only because rule 2 needs something to dispose.
62+
63+
**Defect, given ownership.** A call that can throw (any invocation or `await`, other than `nameof`/`typeof`) lies
64+
between the acquisition and the hand-over, and no enclosing `try` disposes `r` on the way out (`catch` or
65+
`finally` that disposes `r`, or a `published`-style flag guarding a dispose).
66+
67+
**F0-B inputs.**
68+
69+
- **Real code**, as single files from GitHub at the pairs' SHAs, parsed with framework references only:
70+
- R: rockbot `McpBridgeService.cs` base and fix;
71+
- B: BareWire `RabbitMqTransportAdapter.cs` base and fix;
72+
- X: NexusKit `PluginHostBuilder.cs` base and fix, plus `PluginHost.cs` at the same SHAs.
73+
- **Negative controls** (`controls/OwnershipControls.cs`, written before the first run). Each must be **silent**:
74+
75+
| # | Control |
76+
|---|---|
77+
| N1 | DI-resolved: `var s = sp.GetRequiredService<IFoo>(); await X(); _foo = s;` with `T` disposing `_foo` (borrow table) |
78+
| N2 | borrowed reference: a parameter stored into a field `T` disposes (not acquired by a call) |
79+
| N3 | shared instance stored in a field `T` never disposes: `var c = Cache.Get(k); await X(); _c = c;` |
80+
| N4 | caching factory into a dictionary `T` never disposes from |
81+
| N5 | factory result handed over **before** any throwing call |
82+
| N6 | the throwing step inside `try`/`catch` that disposes `r` and rethrows (the fixes' shape) |
83+
| N7 | `published` flag + `finally` disposal (the rockbot fix's shape) |
84+
| N8 | factory result used and disposed locally with `using` (no hand-over) |
85+
| N9 | factory result returned to the caller (rule 1 has no return case: silent by design, an inter-procedural case) |
86+
87+
- **Positive samples**, which must be reported: the R, X and B shapes in synthetic form, P-R, P-X and P-B.
88+
89+
**F0-B pass.** All three of:
90+
91+
- the oracle reports the real R, X and B base files at the acquisition, with ownership established by rules 1–2,
92+
each citing the disposing line it found;
93+
- it is silent on their fix files;
94+
- N1–N9 are silent and P-R, P-X and P-B are reported.
95+
96+
Any failure → `KILL-N1`. The oracle must not be changed after the first run on the real files; control files may
97+
be extended.
98+
99+
The oracle is a throwaway research scan (Roslyn, the same build route as the #416 PoC). It is not an Own.NET
100+
implementation.
101+
102+
## F0-C — cost
103+
104+
Read the extractor (`frontend/roslyn/OwnSharp.Extractor/Program.cs`) and OwnIR (`ownlang/ownir.py`) and answer
105+
three questions with file and line references:
106+
107+
1. Can the extractor emit an acquisition for a call result (rather than `new`) using an existing OwnIR fact kind?
108+
2. Can it emit the hand-over (field, collection or wrapper store) as an existing release/move fact?
109+
3. Are throw edges between acquisition and hand-over already expressible (the `--body-throw-edges` tier)?
110+
111+
**F0-C kill.** Any of the three needs a new OwnIR fact kind, a version bump, or a new interprocedural pass in the
112+
core → `KILL-N1`. Needing only extractor-side work (emitting existing facts from new syntax) is acceptable, and
113+
the estimate is reported.
114+
115+
## Verdict
116+
117+
`PASS-F0` only if F0-A, F0-B and F0-C all pass. Otherwise `KILL-N1`, naming the first failing part.
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
# N1-F0 — ownership feasibility gate — report
2+
3+
**Result: `KILL-N1`. The first failing part is F0-B, and the failure is in the may-throw model, not in ownership.**
4+
5+
| Part | Verdict | Short reason |
6+
|---|---|---|
7+
| F0-A competitor check | **pass** | R, X, B stay `MISSED` under every `interprocedural_analysis_kind` and ownership-transfer setting, with every disposal-related diagnostic inside the method collected |
8+
| F0-B ownership oracle | **fail** | ownership is established on all three real base files without name/type/`await` heuristics, but the **BareWire fixed file is flagged**: `Guid.NewGuid().ToString("N")` counts as a may-throw call before the hand-over |
9+
| F0-C cost | pass (not decisive) | acquisition, hand-over and throw edges fit existing OwnIR ops; the existing throw model is the same coarse one that failed F0-B |
10+
11+
Rules: [`00-preregistration.md`](00-preregistration.md), committed in `66731cc2` before any F0 run. The oracle was
12+
frozen in `081b68ee`, after its control-only debugging and before the one scored run on real files. Time used:
13+
21:51–21:58 +05 on 2026-10-20 for the runs, inside the 0.25 agent-day budget.
14+
15+
No production, OwnIR, CI or golden change. Nothing third-party was built or downloaded.
16+
17+
## F0-A
18+
19+
[`scripts/run_f0a.py`](scripts/run_f0a.py) rebuilt the #417 reproductions under 11 configurations: A1–A6 cover
20+
`interprocedural_analysis_kind` `None` / `NonContextSensitive` / `ContextSensitive`, each with and without both
21+
ownership-transfer switches; K1–K5 repeat the #417 set. The collector takes every disposal-related diagnostic
22+
located inside the buggy method's line span (`data/f0a_spans.tsv`), not only on the `ACQ` line.
23+
24+
Result (`data/f0a_method_diagnostics.tsv`):
25+
26+
| Pair | Result |
27+
|---|---|
28+
| W | CA2000 on `new ServiceBusClient(…)` in A1–A6 and K2–K4 (`CAUGHT`) |
29+
| R | only CA2000 on `out var oldClient`, line 28, in all 11 configurations. That is another object, `ADJACENT`, so R stays `MISSED` |
30+
| X | nothing (`MISSED`) |
31+
| B | nothing (`MISSED`) |
32+
33+
3 pairs missed, against the required ≥ 2, so F0-A passes. No interprocedural setting changes anything.
34+
35+
## F0-B
36+
37+
**The oracle.** [`oracle/Program.cs`](oracle/Program.cs), a Roslyn scan, applies rules 1–3 as registered.
38+
Ownership needs a hand-over target that the repository itself disposes, and the borrow table only suppresses. Two
39+
details were fixed in the code header before the real run:
40+
41+
- **"Disposes r"** means any of: `Dispose`/`DisposeAsync`/`Close`/`CloseAsync` on `r`; the same call on an owning
42+
wrapper `new W(r)`; or `r` passed to a method of the same type that disposes its parameter.
43+
- **One control-driven fix.** A dispose of `r` inside the `catch` is not a throwing site (control N6).
44+
45+
**Controls** (`data/f0b_controls.tsv`). N1–N9 are silent: DI-resolved, borrowed parameter, shared and cached
46+
instances never disposed, hand-over first, `try`/`catch` dispose, `published` flag, `using`, returned. P-R, P-B and
47+
P-X are reported.
48+
49+
**Real files** (`data/f0b_real.tsv`). Single files at the pairs' SHAs (hashes in `data/f0b_inputs.sha256`), parsed
50+
with framework references only.
51+
52+
| File | Oracle | Ownership evidence it found |
53+
|---|---|---|
54+
| R base `McpBridgeService.cs` | reported at `McpClient.CreateAsync` (l. 561); throw at `await newClient.ListToolsAsync` (l. 564); hand-over `_clients[name]` (l. 577) | `_clients` element disposed: `oldClient.DisposeAsync()` (l. 573), with `oldClient` taken by `_clients.Remove(name, out var oldClient)` |
55+
| R fix | silent | **no owned acquisition**: the fix hands the client over through `new ConnectionResources(newClient)` assigned to a local and an object-initializer member, which rules 1a–1c do not cover. Silent, but not because the cleanup was recognised |
56+
| X base `PluginHostBuilder.cs` + `PluginHost.cs` | reported at `BuildServiceProvider` (l. 90); first throw at `provider.GetService<…>()` (l. 92); hand-over `return new PluginHost(provider)` (l. 136) | wrapper rule 1c: `PluginHost.mProvider` disposed at `PluginHost.cs:27` |
57+
| X fix | silent | the `catch` disposes `new PluginHost(provider)`, an owning wrapper |
58+
| B base `RabbitMqTransportAdapter.cs` | reported at `CreateChannelAsync` (l. 285); first throw listed at `Math.Min(…)` (l. 294), and `BasicQosAsync` lies in the same window; hand-over `_activeConsumerChannels[id]` (l. 321) | `channel.CloseAsync(…)` (l. 249) on a channel taken from `_activeConsumerChannels` |
59+
| **B fix** | **reported** (FP) | ownership is right, but the only calls between `CreateChannelAsync` (l. 286) and the early hand-over (l. 300) are `Guid.NewGuid().ToString("N")` (l. 299). The registered rule "any invocation can throw" counts them |
60+
61+
By the registered pass rule, which requires silence on every fix file, F0-B fails. The oracle was not changed after
62+
this run.
63+
64+
**What the failure means.**
65+
- **Confirmed.** The ownership half works on these inputs:
66+
- it found the disposing line for all three real targets;
67+
- it stayed silent on all nine negative controls without any `IDisposable`, `Create*` or `await` cue;
68+
- in X and B it established ownership across a wrapper and across a collection.
69+
- **Confirmed.** The registered may-throw rule is too coarse. One of three fixes is flagged only because of
70+
non-throwing BCL calls.
71+
- **Inference, not scored.** A may-throw model that exempts such calls would clear B fix while keeping B base,
72+
because `BasicQosAsync` lies inside its window. Nothing was run to show it.
73+
- **Limit.** The R fix shows a recall gap: a hand-over through a local wrapper or an object initializer establishes
74+
no ownership. A bug in that shape would be missed.
75+
76+
## F0-C
77+
78+
Read with line references in `main` (`38fb952d`):
79+
80+
1. **Acquisition from a call.** Already expressible: the flow-locals pass emits `acquire` for `new`, pool rents and a
81+
curated owning-factory table (`IsOwningFactory`, `frontend/roslyn/OwnSharp.Extractor/Program.cs:3832-3837`).
82+
For first-party factories the core can mint the acquire itself from a `call` op (P-005 D5.2,
83+
`Program.cs:3849`). An oracle-based acquire would be extractor-side only.
84+
2. **Hand-over.** The existing `release` op can discharge the obligation (`ownlang/ownir.py:343-347`,
85+
`_FLOW_OPS`). Today a local stored to a field "escapes" and is not flow-tracked at all (`--flow-locals` covers
86+
non-escaping locals only, `Program.cs:84-87`). Changing that is extractor-side work.
87+
3. **Throw edges.** These exist (`InjectThrowEdge`, `Program.cs:3569-3587`, `--body-throw-edges`). They use
88+
`StatementMayThrow` (`Program.cs:3590-3599`): any non-dispose call or `new` may throw. That is the same coarse
89+
model that failed F0-B, and the extractor itself calls this tier "the CA2000 firehose" (`Program.cs:100-103`).
90+
91+
None of the three needs a new OwnIR op, a version bump or a core interprocedural pass, so F0-C passes by its rule.
92+
It also shows that a precise may-throw model does not exist yet; it would be new extractor work.
93+
94+
Estimate, an inference: the oracle port plus escape tracking is roughly several days of extractor work. A may-throw
95+
model precise enough to clear cases like B fix has no estimate, because nothing like it exists in the repository.
96+
97+
## Files
98+
99+
- [`00-preregistration.md`](00-preregistration.md)
100+
- [`controls/OwnershipControls.cs`](controls/OwnershipControls.cs)
101+
- [`oracle/`](oracle/): the oracle, a Roslyn console, built like the #416 PoC.
102+
- [`scripts/run_f0a.py`](scripts/run_f0a.py)
103+
- `data/`:
104+
- `f0a_method_diagnostics.tsv`, `f0a_spans.tsv`: F0-A;
105+
- `f0b_controls.tsv`, `f0b_real.tsv`, `f0b_inputs.sha256`: F0-B.
106+
107+
The third-party source files are not committed; they are named by SHA and hash.
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
// N1-F0 oracle controls (00-preregistration.md, F0-B). Written before the first run.
2+
// Methods N<n>_* must be SILENT; methods P_* must be REPORTED.
3+
using System;
4+
using System.Collections.Concurrent;
5+
using System.Collections.Generic;
6+
using System.Threading.Tasks;
7+
using Microsoft.Extensions.DependencyInjection;
8+
9+
namespace OwnershipControls;
10+
11+
public interface IFoo : IAsyncDisposable { Task PingAsync(); }
12+
public interface IConn : IAsyncDisposable { Task<IFoo> OpenAsync(); }
13+
14+
public static class Cache
15+
{
16+
public static IFoo Get(string key) => throw new NotSupportedException();
17+
}
18+
19+
public static class Remote
20+
{
21+
public static Task<IFoo> CreateAsync(string url) => throw new NotSupportedException();
22+
}
23+
24+
public sealed class Wrap : IAsyncDisposable
25+
{
26+
private readonly IFoo _inner;
27+
public Wrap(IFoo inner) { _inner = inner; }
28+
public ValueTask DisposeAsync() => _inner.DisposeAsync();
29+
}
30+
31+
public sealed class Holder : IAsyncDisposable
32+
{
33+
private readonly IServiceProvider _sp;
34+
private readonly IConn _conn;
35+
private IFoo? _foo;
36+
private IFoo? _shared;
37+
private readonly ConcurrentDictionary<string, IFoo> _owned = new();
38+
private readonly Dictionary<string, IFoo> _cache = new();
39+
40+
public Holder(IServiceProvider sp, IConn conn) { _sp = sp; _conn = conn; }
41+
42+
private static Task StepAsync() => Task.Delay(1);
43+
44+
// ---- negative controls: must be silent ----
45+
46+
public async Task N1_DiResolved()
47+
{
48+
var s = _sp.GetRequiredService<IFoo>();
49+
await StepAsync();
50+
_foo = s;
51+
}
52+
53+
public async Task N2_Borrowed(IFoo given)
54+
{
55+
await StepAsync();
56+
_foo = given;
57+
}
58+
59+
public async Task N3_SharedNeverDisposed(string k)
60+
{
61+
var c = Cache.Get(k);
62+
await StepAsync();
63+
_shared = c;
64+
}
65+
66+
public async Task N4_CachingFactoryNeverDisposed(string k)
67+
{
68+
var c = await Remote.CreateAsync(k);
69+
await c.PingAsync();
70+
_cache[k] = c;
71+
}
72+
73+
public async Task N5_HandOverFirst(string k)
74+
{
75+
var c = await Remote.CreateAsync(k);
76+
_owned[k] = c;
77+
await c.PingAsync();
78+
}
79+
80+
public async Task N6_TryCatchDispose(string k)
81+
{
82+
var c = await Remote.CreateAsync(k);
83+
try
84+
{
85+
await c.PingAsync();
86+
}
87+
catch
88+
{
89+
await c.DisposeAsync();
90+
throw;
91+
}
92+
_owned[k] = c;
93+
}
94+
95+
public async Task N7_PublishedFlag(string k)
96+
{
97+
IFoo? c = null;
98+
var published = false;
99+
try
100+
{
101+
c = await Remote.CreateAsync(k);
102+
await c.PingAsync();
103+
_owned[k] = c;
104+
published = true;
105+
}
106+
finally
107+
{
108+
if (!published && c is not null) await c.DisposeAsync();
109+
}
110+
}
111+
112+
public async Task N8_LocalUsing(string k)
113+
{
114+
await using var c = await Remote.CreateAsync(k);
115+
await c.PingAsync();
116+
}
117+
118+
public async Task<IFoo> N9_Returned(string k)
119+
{
120+
var c = await Remote.CreateAsync(k);
121+
await c.PingAsync();
122+
return c;
123+
}
124+
125+
// ---- positive samples: must be reported ----
126+
127+
// P-R: awaited factory, throwing call on the result, then stored into a dictionary T disposes from
128+
public async Task P_R(string k)
129+
{
130+
var c = await Remote.CreateAsync(k);
131+
await c.PingAsync();
132+
_owned[k] = c;
133+
}
134+
135+
// P-B: awaited interface factory, throwing call, then dictionary store
136+
public async Task P_B(string k)
137+
{
138+
IFoo ch = await _conn.OpenAsync();
139+
await ch.PingAsync();
140+
_owned[k] = ch;
141+
}
142+
143+
// P-X: factory result wrapped in an in-source type that disposes it, after a throwing call
144+
public async Task<Wrap> P_X()
145+
{
146+
var c = await Remote.CreateAsync("x");
147+
await StepAsync();
148+
return new Wrap(c);
149+
}
150+
151+
public async ValueTask DisposeAsync()
152+
{
153+
if (_foo is not null) await _foo.DisposeAsync();
154+
foreach (var v in _owned.Values) await v.DisposeAsync();
155+
if (_owned.TryRemove("k", out var one)) await one.DisposeAsync();
156+
}
157+
}

0 commit comments

Comments
 (0)