Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions docs/evidence/n3-pilot-2026-10/00-preregistration-g0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# N3 bounded pilot, gate G0 (scope identity) — pre-registration

Written 2026-10-20, before the G0 filter was written or run. What had been read before this file:

- the D11 triage of the 7 holdout DI001 findings (`evidence/holdout/ownnet/di_sample.tsv` of the direction
research, private), which names the cause of each FP;
- `ownlang/di.py` (`find_captive_dependencies`);
- the field names of one `services` fact in the OrchardCore holdout facts. No DI001 result was recomputed.

Authority: owner decision `ACCEPT-PHASE0 / GO-N3-BOUNDED-PILOT / NO-PRODUCTION`, given on PR #414. G0 budget:
0.25 agent-day. G1–G4 start only after a G0 PASS, and get one agent-day in total. The first failed mandatory gate
stops the pilot (`KILL-N3-PILOT`). The budget is not extended.

## Question

Can product registrations be told apart from test registrations, and can each registration be resolved through its
own implementation, using only the facts the extractor already emits and the repository's project files? The DI
graph must not be rebuilt.

N3 needs this because its warning must rest on a proven scoped lifetime of a concrete object. If the registration
graph cannot say "scoped" without the 7/7 FP of D11, a detached-work rule built on it inherits them.

## Known failure (the target)

7 of 7 product DI001 findings on OrchardCore (holdout, target `5ddc4594`, Own.NET `e7d33e33`) are FP. The D11 triage
gives two causes:

- **test registrations merged in (6).** `IExtensionManager` (5 findings) and `IMediaTokenService` (1) are
`AddSingleton` in product code. The `scoped` lifetime comes from test registrations (`StubExtensionManager`,
`MediaTokenTests.cs`). The core keys the graph by service name, and the last registration wins.
- **implementations merged by service name (1).** `IDeploymentTargetProvider`: the singleton implementation
`FileDownloadDeploymentTargetProvider` takes only `IStringLocalizer`. The scoped dependency belongs to another
implementation of the same interface. The core starts each walk from `by_name[s.name]`, not from the registration
`s` itself.

## Method (fixed now)

A research script outside the product tree reads saved `facts.json` files and the repository sources. Production
code, OwnIR, CI and goldens are not changed. The script applies four rules:

1. **Test project.** A registration belongs to the project whose `.csproj` is in the nearest ancestor directory of
its `file`. The project is a test project when its `.csproj` sets `<IsTestProject>true</IsTestProject>` or
references one of `Microsoft.NET.Test.Sdk`, `xunit`, `xunit.v3`, `NUnit`, `MSTest.TestFramework`, `MSTest`,
`TUnit` or `BenchmarkDotNet`. Path names are not used for this decision. The path regex of the direction
research (`sweep_summary.py`) is reported next to it, as a comparison only.
2. **Drop test registrations.** Registrations in test projects are removed. A registration with no owning
`.csproj` is kept and counted.
3. **Own implementation at the root.** The walk for a singleton registration starts from that registration's own
`deps`, not from the node of the same service name.
4. **Proven lifetime only.** A dependency name counts as scoped only when every remaining registration of that
name is scoped. It is followed as transient only when every remaining registration is transient. Mixed
lifetimes make the name unknown, and unknown adds no edge. The number of mixed names is reported.

The DI001 rule itself (singleton → scoped, through transients) does not change.

## Inputs

- **Target.** OrchardCore holdout facts as saved by D11 (Own.NET `e7d33e33`). They are not re-extracted.
- **Positive controls (must keep firing).**
- `frontend/roslyn/samples/DiCaptiveSample.cs`: exactly four DI001 (`EmailSender -> AppDbContext`,
`ReportService -> UnitOfWork -> AppDbContext`, `CacheService -> IRepo`, `PrimaryCtorService -> AppDbContext`)
and silence on `Metrics -> Clock` and `PublicCtorOnly`, as its CI assertions pin today.
- Corpus pair `rrr-pi-rate/StockPriceSheetPrintService#60` (C10): DI001 present on `base`, absent on `fix`, as in
the D6 ledger.
- **New-finding check.** The other 14 holdout repositories and the 13 design-B sweep repositories, all from saved
facts. Rule 3 can add findings that the current core does not report, so every finding that the current core
does not report is listed and triaged by reading the code.

## Verdict rules

`PASS-G0` needs all of:

- all 7 known OrchardCore FPs are gone, each by a rule named above;
- both positive controls hold exactly;
- every new finding is triaged, and none is a FP. An `UNCLEAR` counts as a FP;
- elapsed work within 0.25 agent-day.

`KILL-N3-PILOT` at G0 if any of the following:

- a known FP survives the four rules and removing it needs another mechanism;
- a positive control is lost;
- a new finding is a FP or `UNCLEAR`;
- the budget runs out before a verdict.

No rule may be added or changed after the first run. A fifth rule invented after the run is a G0 failure, not a
fix.

## Out of scope for G0 (input to G1)

- Framework registrations (`AddDbContext`, `AddHttpClient`, controllers, Razor components). Their lifetime comes from
the framework, not from an `Add*` call in the repo. G1 must say how N3 gets "scoped" for them; G0 does not test it.
- Open generics, factory lambdas, assembly scanning: unknown, as today.
105 changes: 105 additions & 0 deletions docs/evidence/n3-pilot-2026-10/REPORT-G0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# N3 bounded pilot, gate G0 (scope identity) — report

**Result: `PASS-G0`, by the pre-registered rules.** Three limits below bind G1.

Rules: [`00-preregistration-g0.md`](00-preregistration-g0.md), committed in `14be6c33` before the filter was
written or run. No rule was changed after the first run.

Nothing in production, OwnIR, CI or goldens was touched. The script reads saved `facts.json` files, `.csproj` files
and the unchanged core (`ownlang/di.py`).

## 1. What was run

- **Script.** [`scripts/g0_scope_identity.py`](scripts/g0_scope_identity.py).
- **Facts.** The facts saved by the direction research (private), produced by the Own.NET `e7d33e33` Release
extractor:
- 15 holdout repositories;
- 13 design-B sweep repositories;
- corpus pair rrr-pi-rate#60, `base` and `fix`.

`frontend/roslyn/samples/DiCaptiveSample.cs` was extracted with the same binary. `di.py` and the sample are
unchanged between `e7d33e33` and `main` (`76bc4222`).
- **Baseline.** The current core, `find_captive_dependencies`, over the same facts.
- **Self-check.**
- With all three rules switched off, the script's walk reproduces the core's findings in all 30 inputs, with 0
differences.
- A second run produced identical output files.

Command:

```
python -I docs/evidence/n3-pilot-2026-10/scripts/g0_scope_identity.py --ownnet . \
--research <direction-research root> --out <dir> \
--extra "sample/DiCaptiveSample=<facts of DiCaptiveSample.cs>@."
```

## 2. Verdict, criterion by criterion

| Criterion | Result |
|---|---|
| All 7 known OrchardCore FPs gone, each by a named rule | yes, 7/7 (§3) |
| `DiCaptiveSample`: exactly four DI001, silence on `Metrics -> Clock` and `PublicCtorOnly` | yes: `EmailSender -> AppDbContext`, `ReportService -> UnitOfWork -> AppDbContext`, `CacheService -> IRepo`, `PrimaryCtorService -> AppDbContext`; nothing else |
| rrr-pi-rate#60: DI001 on `base`, none on `fix` | yes: `IDiscordBotMessageReceiver -> ITriggerReportService` on `base`; 0 on `fix` |
| New findings over 27 holdout and sweep repositories, all triaged, none FP or `UNCLEAR` | 0 new findings |
| Within 0.25 agent-day | yes |

## 3. Which rule removed which FP (ablation)

[`data/g0_ablation.tsv`](data/g0_ablation.tsv) records every subset of rules 2, 3 and 4. A `1` means the finding is
still reported.

| Known FP (OrchardCore) | none | 2 | 3 | 4 | 2+3 | 2+4 | 3+4 | 2+3+4 |
|---|---:|---:|---:|---:|---:|---:|---:|---:|
| `IShellHost -> IExtensionManager` | 1 | 1 | 1 | 0 | 1 | 0 | 0 | 0 |
| `ICompositionStrategy -> IExtensionManager` | 1 | 1 | 1 | 0 | 1 | 0 | 0 | 0 |
| `IShellContainerFactory -> IExtensionManager` | 1 | 1 | 1 | 0 | 1 | 0 | 0 | 0 |
| `IShellDescriptorManager -> IExtensionManager` | 1 | 1 | 1 | 0 | 1 | 0 | 0 | 0 |
| `IPageApplicationModelProvider -> IExtensionManager` | 1 | 1 | 1 | 0 | 1 | 0 | 0 | 0 |
| `MediaCommandParser -> IMediaTokenService` | 1 | 1 | 1 | 0 | 1 | 0 | 0 | 0 |
| `IDeploymentTargetProvider -> RemoteInstanceService` | 1 | 1 | 0 | 1 | 0 | 1 | 0 | 0 |

- Rule 3 (walk from the registration's own implementation) removes the implementation-merge FP. Nothing else
removes it.
- Rule 4 (proven lifetime only) removes the six test-registration FPs.
- Rule 2 (drop test registrations) removes none of the seven (limit 1).
- Partial subsets add findings: rule 3 alone gives 2 new ones, rules 3+4 give 1. All of them are in nopCommerce test
code (`src/Tests/Nop.Tests/BaseNopTest.cs:482`, `ITaskScheduler`). With all three rules on, there are 0.

## 4. Limits that bind G1

1. **The test-project rule missed the project that holds the FP source.** `test/OrchardCore.Tests` references
`xunit.v3.mtp-v2`, which is not in the fixed package list, so rule 1 classed it as product.
- The `StubExtensionManager` and `MediaTokenTests` registrations stayed in the graph. They were neutralised only
because product code registers the same names as singleton, so rule 4 saw mixed lifetimes.
- A scoped registration that exists only in an unrecognised test project would still read as proven scoped.
- One existing DI001 on a test-file registration also stays: `AttachedMediaFieldFileService ->
IUserAssetFolderNameProvider`, `ViewMediaFolderAuthorizationHandlerTests.cs:461`.
- Over all inputs, rule 1 marks 603 of 4,503 registrations as test, and the comparison path regex marks 1,682.
The regex also covers samples, docs and benchmarks, so the two numbers do not measure the same thing.
- Package-name matching is brittle. G1 must not treat "not in a recognised test project" as proof of product
code.
2. **Rule 4 trades recall for precision.** Names with mixed lifetimes become unknown: 48 over all inputs: 23 in
OrchardCore, 11 in Orleans, 6 in bitwarden, 8 in eShop, grpc-dotnet, Quartz.NET and nopCommerce. For N3 this direction is acceptable:
unknown means silence.
3. **A known FP survives G0.** In Orleans, `DhtGrainLocator -> IGrainContext` (`DefaultSiloServices.cs:156`) was
triaged FP in the sweep: the singleton is built by a factory lambda that never resolves `IGrainContext`. It is not
one of the seven targets, and G0 does not fix it. The two nopCommerce `ElFinderService` findings that remain were
triaged true but benign in the sweep.
4. **G0 does not cover where N3's scoped objects mostly come from.** In the Phase 0 instances these are
`HttpContext`, `DbContext` registered by `AddDbContext`, and controllers or other per-request framework types.
Their lifetime is set by the framework, not by an `Add*` call in the repository, so the registration graph alone
cannot prove them scoped. G1 has to settle this first, and a framework lifetime table must be fixed before any
run.

## 5. Files

- [`00-preregistration-g0.md`](00-preregistration-g0.md): the rules, committed before the run.
- [`scripts/g0_scope_identity.py`](scripts/g0_scope_identity.py): the filter and the ablation.
- [`data/g0_summary.tsv`](data/g0_summary.tsv): per input, the registrations, test-project and path-regex counts,
mixed-lifetime names, and DI001 counts for the current core and for G0.
- [`data/g0_findings.tsv`](data/g0_findings.tsv): every current-core finding, marked `kept` or `removed`, and any
`NEW` finding.
- [`data/g0_ablation.tsv`](data/g0_ablation.tsv): every finding under every rule subset.

The input facts and the third-party source trees are in the direction-research repository (private) and are not
committed here.
Loading
Loading