[ADR-0071] Compono.Logging: Host-Wide Log Capture (LogCollector + AddComponoCapture)¶
Status: Accepted
Date: 2026-11-10
Decision Makers: product owner (maintainer interview, 2026-11-10), assisted by Claude Opus 5.5
Context¶
Issue #164 (child of #161, item 1): a Generic Host test needs to assert that nothing was logged at Error or above in any category. That includes framework and third-party categories (the hosting layer, a scheduler) that the test never composes. The host's own ILoggerFactory creates those loggers, not Compono. Today the consumer keeps Microsoft.Extensions.Diagnostics.Testing (AddFakeLogging() + FakeLogCollector) for this one guard, which gives the suite two log-capture models and two verification vocabularies.
What Compono.Logging does today, per ADR-0055 and src/Compono.Logging:
UseLogging()registers a stage-6 provider. That provider composesILogger/ILogger<T>asCapturingLogger/CapturingLogger<T>.- Each capturing logger owns a private, internal
LogEntryCollector(lock + list + its ownLoggerExternalScopeProvider). There is no collector shared across loggers. CapturedLogEntryhas no category.- There is no
ILoggerProvider/ILoggingBuilderintegration. ADR-0055's "ILoggerFactory: out of scope" boundary deferred this "until real evidence demands it". #161's migration is that evidence: capability admission Gate B is met per RESEARCH-0036. Gate A is met because this is a material extension of an already-admitted package's own scope, not a new capability or package.
The facts below were established empirically on 2026-11-10 with a throwaway spike (class library on net8.0 + net11.0 referencing only Microsoft.Extensions.Logging.Abstractions, IsAotCompatible=true, driven by a real Host.CreateApplicationBuilder() on SDK 11.0.100):
ILoggerProvider,ISupportExternalScope,ILoggingBuilder, andProviderAliasAttributeall live inMicrosoft.Extensions.Logging.Abstractions.ServiceDescriptor/IServiceCollectionregistration helpers come transitively throughMicrosoft.Extensions.DependencyInjection.Abstractions, whichLogging.Abstractionsalready depends on. A provider-based integration needs no new package dependency. Provider-specific filter rules (AddFilter<TProvider>,LoggerFilterOptions) andAddProviderlive in the concreteMicrosoft.Extensions.Loggingpackage, whichCompono.Loggingdeliberately doesn't reference.- The host's default minimum level is
Information, soDebug/Tracenever reach any provider unless the test lowers it. Host categories (Microsoft.Hosting.Lifetime, an arbitraryILoggerFactory.CreateLogger("Some.Framework.Category"), andILogger<T>resolved from the container) all reach the provider with their real category strings. - When a provider implements
ISupportExternalScope, the factory hands it the factory's scope provider.BeginScopeon a hostILogger<T>then pushes onto that provider, andForEachScopeenumerates outermost to innermost, still correct after anawait. This matches ADR-0055's per-logger scope semantics. - A provider registered as a ready-made instance is never disposed by the host. A provider registered through a factory delegate is disposed on
host.Dispose(). LoggerFactory.CreateLogger<T>()uses this category name: the full type name with nested types joined by.(not+) and the generic arity and arguments removed. Built-in types use their C# keyword (N.Outer+Inner→N.Outer.Inner,N.Gen<int>→N.Gen,List<N.Outer.Inner>→System.Collections.Generic.List,int→int).
Decision Drivers¶
- Satisfy the real consumer scenario (host-wide, cross-category, level-threshold guard) with the smallest maintainable public surface.
- Zero behavior change for existing
Compono.Loggingconsumers: issue #164's acceptance criteria forbid breakingCapturingLogger,CapturedLogEntry, orUseLogging(). - No new package dependency, and no new package (the issue intake already argued a separate integration package isn't justified).
- Native AOT and trimming:
IsAotCompatiblestaystrueand no reflection is used. - Test isolation under parallel execution: no static or ambient capture state, and nothing is captured globally by accident.
- One verification vocabulary: reuse
CapturedLogEntryandLogVerificationBuilder(and through it, coreCallVerifier). - Don't replace or wrap the host's
ILoggerFactory. The host's filter pipeline stays the authority on what gets logged.
Considered Options¶
Axis 1: how the test reaches the captured host logs.
- An explicit, test-owned public
LogCollector, passed to anILoggingBuilderextension. The provider type stays internal. - A public
ILoggerProvidertype that is itself the handle (AddProvider(provider), thenprovider.Verify()). - A collector registered as a DI singleton by
AddComponoCapture()and resolved fromhost.Services(theFakeLogCollectorpattern).
Axis 2: whether composed loggers and host loggers share a destination.
- Separate in v1: host capture goes only to its
LogCollector, and composed loggers stay per-logger exactly as today. - Opt-in forwarding (
UseLogging(o => o.Collector = logs)): composed loggers also append to a given collector. - One store for everything (composed loggers share a default collector).
Axis 3: level filtering for host capture.
- Host filter rules only.
LogCollectortakes no options. - Host filter rules, then an extra
LoggingOptions.MinimumLevelgate on the collector. - Force-capture everything (a provider-specific
Tracerule injected byAddComponoCapture). Not achievable without a newMicrosoft.Extensions.Loggingdependency (spike fact 1).
Axis 4: cross-category verification vocabulary.
- Add
AtLevelOrAbove(LogLevel)andInCategory(string)toLogVerificationBuilder. - Add
AtLevelOrAbove(LogLevel)only. - No new filters. Use
Matching(...).
Decision Outcome¶
Chosen per axis (all confirmed in the maintainer interview, 2026-11-10): Axis 1, Option 1 (explicit LogCollector); Axis 2, Option 1 (separate in v1); Axis 3, Option 1 (host rules only); Axis 4, Option 1 (AtLevelOrAbove + InCategory). Category naming for composed loggers matches LoggerFactory exactly (below).
Package identity and dependency graph¶
Unchanged from ADR-0055. Everything ships in Compono.Logging:
Compono.Logging
-> Compono
-> Microsoft.Extensions.Logging.Abstractions (already referenced; brings DI.Abstractions transitively)
No new PackageReference in Compono.Logging.csproj. Explicitly not added: Microsoft.Extensions.Logging, Microsoft.Extensions.Hosting, Microsoft.Extensions.Options, Microsoft.Extensions.DependencyInjection (the implementation), or Microsoft.Extensions.Diagnostics.Testing. Test projects may reference Microsoft.Extensions.Hosting/ Microsoft.Extensions.Logging as test-only dependencies to drive a real host.
Public API (additive only)¶
namespace Compono.Logging;
// NEW. Test-owned, thread-safe destination for host-created loggers.
public sealed class LogCollector
{
public LogCollector();
public IReadOnlyList<CapturedLogEntry> GetCapturedEntries(); // snapshot, capture order
public CapturedLogEntry? GetLastCapturedEntry();
public void ClearCapturedEntries();
public LogVerificationBuilder Verify();
}
// NEW. The only way to attach a LogCollector to a host's logging pipeline.
public static class ComponoLoggingBuilderExtensions
{
public static ILoggingBuilder AddComponoCapture(this ILoggingBuilder builder, LogCollector collector);
}
// CHANGED (additive): one new property.
public readonly record struct CapturedLogEntry
{
public string Category { get; } // never null
// ... every existing member unchanged ...
}
// CHANGED (additive): two new filters.
public sealed class LogVerificationBuilder
{
public LogVerificationBuilder AtLevelOrAbove(LogLevel level);
public LogVerificationBuilder InCategory(string category);
// ... every existing member unchanged ...
}
Consumer shape:
var logs = new LogCollector();
var builder = Host.CreateApplicationBuilder();
builder.Logging.AddComponoCapture(logs);
using var host = builder.Build();
await host.StartAsync();
// ... exercise the system ...
await host.StopAsync();
logs.Verify().AtLevelOrAbove(LogLevel.Error).Never();
logs.Verify().InCategory("Quartz.Core.QuartzScheduler").AtLevel(LogLevel.Warning).Once();
The same call works anywhere an ILoggingBuilder exists, for example services.AddLogging(b => b.AddComponoCapture(logs)), or WebApplicationBuilder.Logging/IHostBuilder.ConfigureLogging. Those are the consumer's own Microsoft.Extensions.* APIs, not Compono surface.
API rules the implementation must follow:
LogCollector's four members have the same names, return types, and semantics as the existingILoggerextension methods inLoggerTestingExtensions(GetCapturedEntries/GetLastCapturedEntry/ClearCapturedEntries/Verify). Reads are snapshots, andVerify()builds a freshLogVerificationBuilderover this collector's entries.LogCollectoris notIDisposable, has no static state, and has no constructor options (Axis 3).AddComponoCapturethrowsArgumentNullExceptionfor a nullbuilderorcollectorand returnsbuilderfor chaining.- No public
ILoggerProvidertype. The provider isinternal sealed, implementsILoggerProviderandISupportExternalScope, and carries[ProviderAlias("Compono")], so a consumer's own configuration-based filter rules (Logging:Compono:LogLevel:...) can target it. The alias costs no dependency (spike fact 1). It's a host filtering mechanism, not a Compono option. LogVerificationBuilder.AtLevelOrAbove(level)matches an entry whenentry.LogLevel >= levelandentry.LogLevel != LogLevel.None.AtLevelOrAbove(LogLevel.None)therefore never matches. Its failure-message fragment readslevel {level} or above.LogVerificationBuilder.InCategory(category)is an exact, ordinal, case-sensitive match onCapturedLogEntry.Category. A null argument throwsArgumentNullException. Its failure-message fragment readscategory "{category}". Prefix and wildcard matching are not provided, so useMatching(...)for those.- Both new filters work identically on a composed logger's
logger.Verify()and onLogCollector.Verify(). ComponoLoggingBuilderExtensionsis named to avoid an ambiguous-type clash withMicrosoft.Extensions.Logging.LoggingBuilderExtensionswhen a consumer imports both namespaces.
CapturedLogEntry.Category¶
- Host-captured entries: the exact category string the host's
ILoggerFactorypassed toILoggerProvider.CreateLogger(category). - Composed or directly constructed
CapturingLogger<T>: the same stringLoggerFactory.CreateLogger<T>()would produce forT(spike fact 5). That means full name,.as the nested-type delimiter, generic arity and arguments removed, and C# keyword aliases for built-in types. Computed without reflection beyondTypename properties (Namespace/Name/DeclaringType/FullName), which are trim- and AOT-safe. Exact parity withLoggerFactoryis a required, tested property. It's computed once per logger instance, not per log call. - Non-generic
CapturingLogger(direct or composed bareILogger):""(empty string). ADR-0055's "Non-genericILogger" decision stands: no category-string constructor is added. - Adding a property to a
readonly record structwhose only constructor isinternalis source- and binary-compatible. ApiCompat must report it as an addition only. - Intentional behavioral compatibility change: record equality. The compiler-synthesized
Equals/GetHashCode/==/!=ofCapturedLogEntrynow includeCategory. Before, two entries that were equal in every other field compared equal. They now compare unequal when their categories differ, and their hash codes may differ. For example, two composedCapturingLogger<A>/CapturingLogger<B>entries that are otherwise identical stop being equal. This is deliberate: category is part of an entry's identity once entries from many categories share one destination. It's kept, not worked around (no customEqualsthat ignoresCategory). The change must be called out in the release notes and indocs/packages/compono-logging.mdas a behavioral, not source/binary, compatibility change. A targeted regression test must pin the new semantics.
Composed vs. host capture: separate in v1¶
- Host-created loggers write only to the
LogCollectorthey were registered with. - Composed
ILogger/ILogger<T>(viaUseLogging()) and directly constructedCapturingLogger/CapturingLogger<T>keep their own per-logger store, exactly as today. Their only visible changes are that their entries now carryCategoryand that entry equality includes it. UseLogging(),LoggingOptions,LoggingProvider, and the generated activation path are unchanged.- A host's
ILogger<T>(fromhost.Services) is the host's ownLogger<T>, not a Compono capturing logger. Callinglogger.Verify()/GetCapturedEntries()on it keeps throwing the existingInvalidOperationException. That message gains one sentence pointing host-logger users atLogCollector/AddComponoCapture. The exception type and behavior don't change. - Opt-in forwarding of composed entries into a
LogCollector(Axis 2, Option 2) is deferred, not rejected. It's additive and can be added later without breaking anything, if real evidence asks for it.
Filtering: host rules only¶
- The host's own filter pipeline (
LoggerFilterOptions: the global minimum, category rules, and provider rules including theComponoalias) decides what reaches the provider.AddComponoCapturenever adds, removes, or changes filter rules. - The provider's loggers report
IsEnabled(level) == (level != LogLevel.None).Log<TState>also checks this first (defense in depth, matching ADR-0055'sMinimumLeveldecision), soLogLevel.Noneis never captured. - Documented consequence: under a default host (minimum
Information),Debug/Traceentries are not captured. A test that wants them lowers the host's own level (builder.Logging.SetMinimumLevel(LogLevel.Trace)or aCompono-alias configuration rule). This is the consumer'sMicrosoft.Extensions.LoggingAPI, not Compono's.
Structured state, exceptions, timestamps¶
Host-captured entries are built with exactly the same rules as composed entries (ADR-0055 "Captured-entry semantics"): Message comes from the caller's formatter. State is the boxed raw state. Properties and MessageTemplate come from the single reflection-free IReadOnlyList<KeyValuePair<string, object>> pattern match. Exception is passed through. Timestamp is DateTimeOffset.UtcNow at capture. The composed and host paths must share one internal implementation of entry construction, never two copies that can drift.
Scopes¶
- The provider implements
ISupportExternalScope. Its loggers' scope snapshot comes from the scope provider the host factory supplies viaSetScopeProvider.BeginScopeon a provider-created logger pushes onto that same scope provider. - If
SetScopeProvideris never called, the provider falls back to its ownLoggerExternalScopeProvider, so scopes still work. CapturedLogEntry.Scopeskeeps ADR-0055's contract: outermost to innermost, a snapshot at capture time, and correct acrossawait.- Documented consequence: when the host enables
ActivityTrackingOptions, the factory's scope provider also yields its activity scope object, and that appears inScopeslike any other scope. Compono doesn't filter it.
Ownership, lifetime, disposal¶
- The test owns the
LogCollector. It's a plain object that is never disposed, reset, or detached by Compono or the host. Reads remain valid after the host is disposed. - The host owns the provider.
AddComponoCaptureregisters the internal provider so the host's container disposes it on host disposal (factory-style registration, spike fact 4). That follows ADR-0022 Amendment 4 / RESEARCH-0015: Compono doesn't take disposal ownership of host infrastructure. - Disposing the provider stops capture through that provider. After the provider is disposed, its loggers report
IsEnabled(...) == false. This includes loggers created earlier that a caller still holds, for example a capturedILogger<T>that logs afterhost.Dispose(). TheirLog<TState>calls are no-ops, so nothing is appended to the collector and nothing throws.BeginScopestill returns a usable disposable and doesn't throw.CreateLoggerafter disposal returns a logger that doesn't capture. Disposal is idempotent and thread-safe. A log call racing with disposal may be captured or dropped, but it never throws or corrupts the collector. - Disposal never clears or invalidates the collector. Every entry captured before disposal stays readable through the test-owned
LogCollector(GetCapturedEntries,GetLastCapturedEntry,Verify).ClearCapturedEntriesstill works. When the same collector is registered with several hosts, disposing one host's provider stops only that provider's capture. Providers belonging to hosts that haven't been disposed keep appending. - Registration cardinality:
- Calling
AddComponoCapture(logs)twice on the same builder with the same collector instance is idempotent: one provider, and no duplicate entries. - Calling it with different collectors on one builder gives each collector its own provider, and each receives every entry the host lets through.
- Registering the same collector with several hosts is allowed: it receives entries from all of them. That's the test's explicit choice.
- These are required behaviors. The registration mechanism is an implementation choice. Note that a naive
TryAddEnumerablededuplicates by implementation type and would silently drop a second, distinct collector, so it must not be used unless the behavior above holds.
Thread safety and isolation¶
- One private lock per
LogCollectorguards appends, clears, and reads. Reads return snapshot copies, following the existingLogEntryCollector/Compono.Httppattern. Entry construction (formatting, structured extraction, scope snapshot) happens outside the lock. Capture order is the order in which appends acquire the lock. - There's no static, ambient, or global state. Each test's collector sees only the hosts that test explicitly registered it with, so parallel tests with separate hosts and collectors never share entries. Nothing is captured from loggers that aren't created by a host the collector was added to.
- The cost of contention on one lock shared by many categories is accepted. Test hosts log at test volume. The concurrency tests must exercise concurrent multi-category logging alongside reads and clears (bounded, per the CI-hang lesson recorded in RESEARCH-0033).
Native AOT and trimming¶
No reflection, no MakeGenericType, and no dynamic code. The provider and collector are ordinary hand-written classes with no generated activation (unlike ILogger<T> composition), because the host, not Compono, instantiates loggers by category string. Category naming for CapturingLogger<T> uses only trim-safe Type name properties. IsAotCompatible stays true, and Compono.Logging.AotSmokeTest must prove host capture under a real Native AOT publish with zero IL2xxx/IL3xxx warnings.
Backward compatibility¶
The public API change is additive only: one new type, one new extension class, one new property, and two new builder methods. No existing member changes signature or nullability. Package validation (ApiCompat against the last stable baseline) must pass with no suppression.
There are exactly two behavioral changes, and both are intentional and documented. The first is CapturedLogEntry record equality, which now includes Category (see "CapturedLogEntry.Category" above). It's a behavioral compatibility change and gets a regression test and a release-note entry. The second is one sentence appended to an existing exception message.
Relationship to ADR-0055¶
This ADR narrows ADR-0055's "ILoggerFactory: out of scope" boundary. Host provider integration is now in scope. Composing or replacing an ILoggerFactory is still out of scope, and so is everything else in ADR-0055's "Explicit initial-package boundaries" (no Serilog or provider-specific behavior, no runner-output routing, no ControlLevel-style toggling, no dependency on Microsoft.Extensions.Diagnostics.Testing). ADR-0055 Amendment 6 records the pointer.
Positive Consequences¶
- The #161 guard (
no Error+ in any category) becomes a single Compono call, andMicrosoft.Extensions.Diagnostics.Testingis no longer needed for logging. (#165 metrics still keeps it in that suite.) - One entry model and one verification vocabulary across composed and host capture.
- No new package, no new dependency, and AOT-safe.
- Existing consumers are unaffected beyond gaining
Category, plus the intentional equality change that comes with it.
Negative Consequences¶
- Two capture destinations exist (per-logger and
LogCollector). A test that composes loggers and runs a host reads them from two places until opt-in forwarding exists. Mitigation: documented, and forwarding is additive later. - Under default host filtering,
Debug/Tracearen't captured. Mitigation: documented, with the one-line host-side fix. CapturedLogEntryequality now includesCategory. This is an intentional behavioral compatibility change. It is documented, has a regression test, and gets a release-note entry.- A still-referenced host logger silently stops capturing once its host is disposed. Mitigation: documented. Assertions belong before disposal or against entries captured before it.
Pros and Cons of the Options¶
Axis 1¶
- Option 1, explicit collector (chosen). Good: smallest surface (provider stays internal), the test owns the handle and its lifetime, it works after disposal, and it needs no DI lookup. Bad: the test has to hold one more variable.
- Option 2, public provider handle. Good: familiar
AddProvideridiom. Bad: makesILoggerProvider/ISupportExternalScopemembers public Compono surface.AddProvideris a concrete-package API. Instance-registered providers are never disposed by the host. - Option 3, DI-resolved collector. Good: mirrors
FakeLogCollector. Bad: retrieval needs a live service provider, a singleton per container is awkward with several collectors, and it's less explicit.
Axis 2¶
- Option 1, separate (chosen). Good: zero behavior change, and the real scenario is host-only. Bad: there are two places to read if a test mixes both.
- Option 2, opt-in forwarding. Good: covers #161's "both into one collector" suggestion. Bad: new
LoggingOptionssurface and a dual-write path with no real call site yet. Deferred, not rejected. - Option 3, one store. Bad: changes what
logger.GetCapturedEntries()returns today, which breaks an #164 acceptance criterion.
Axis 3¶
- Option 1, host rules only (chosen). Good: the host stays the single authority, and there's no option surface. Bad: no Compono-side threshold, but the host already has one.
- Option 2, extra
MinimumLevelgate. Good: symmetric withCapturingLogger(LoggingOptions?). Bad: two gates to reason about, for no evidenced need. - Option 3, force-capture. Bad: needs a new
Microsoft.Extensions.Loggingdependency, and overrides the host's own configuration.
Axis 4¶
- Option 1 (chosen). Good: readable failure messages for the two checks the scenario actually needs. Bad: two more public members.
- Option 2. Good: one member. Bad: category checks fall back to "a custom condition" messages.
- Option 3. Good: no surface. Bad: the scenario's headline assertion reads as a lambda with an opaque failure message.
Amendment 1 (2026-11-11) — [ProviderAlias] availability is net10.0+ only¶
Implementation (PLAN-0072 B2/B3) found that the "Package identity and dependency graph" fact above — that ProviderAliasAttribute is available from Microsoft.Extensions.Logging.Abstractions alone — holds only on net10.0/net11.0. On net8.0/net9.0 the attribute lives in the concrete Microsoft.Extensions.Logging implementation package, which this package must not reference (the shipped graph above stays Abstractions-only, and B7 requires the packed dependency groups to remain byte-for-byte identical to main). Unconditional use failed to compile on net8/net9 with CS0246: ProviderAliasAttribute could not be found.
Resolution: the [ProviderAlias("Compono")] attribute on the internal provider is compiled only under NET10_0_OR_GREATER. Consequences, unchanged in intent but now explicitly version-scoped:
- The
Logging:Compono:LogLevelprovider-specific filter rule works on net10.0/net11.0; tests covering it are#if NET10_0_OR_GREATER. - On net8.0/net9.0, consumers use the global host minimum (
SetMinimumLevel) or categoryAddFilterrules instead. - Host-wide capture,
LogCollector, filtering, ownership/disposal, and every other decision in this ADR are unaffected on all four TFMs.
No public API or dependency changes.
Amendment 2 (2026-11-11) — the Compono alias works on every target framework¶
Independent review of PR #177 found that Amendment 1's premise was imprecise and its conclusion unnecessary. This amendment corrects it; Amendment 1's text stays as recorded.
Corrected facts (verified against the restored packages and by experiment):
- Availability tracks the Abstractions package version, not the target framework.
ProviderAliasAttributeis absent from every lib asset ofMicrosoft.Extensions.Logging.Abstractions8.0.2 and 9.0.23, and present in every lib asset (includinglib/net8.0) of 10.0.15 and 11.0.0. Amendment 1'sNET10_0_OR_GREATERsplit only lined up with that because this package pins Abstractions to the 8.x/9.x line on net8.0/net9.0 (Directory.Packages.props). Microsoft.Extensions.Loggingresolves a provider's alias by the attribute's full type name (CustomAttributeData, comparing"Microsoft.Extensions.Logging.ProviderAliasAttribute"), not by type identity. A locally defined attribute with that exact full name is honored; the same simple name in another namespace is not.- Consequence of Amendment 1 as shipped on net8.0/net9.0:
Logging:Compono:*rules were silently ignored, whileLogging:Compono.Logging.ComponoLogCollectorProvider:*(the internal type's full name) did work — an accidental configuration surface.
Decision: keep [ProviderAlias("Compono")] unconditional. On net8.0/net9.0 (#if !NET10_0_OR_GREATER) the package compiles an internal Microsoft.Extensions.Logging.ProviderAliasAttribute with the framework attribute's exact full name and constructor shape; on net10.0/net11.0 the real attribute from Abstractions is used and the copy compiles to nothing.
Verified before adopting it:
- Recognition:
Logging:Compono:LogLevel:*(default and category-prefix rules) applies to the Compono provider and not to other providers, under a real Generic Host, inCompono.Logging.Testson all four TFMs. - No new dependencies: the project file and packed dependency groups are unchanged.
- No type-identity collision: the copy is internal, so consumers never see it. A net8.0/net9.0 consumer that references both Compono.Logging and
Microsoft.Extensions.Loggingand applies the public[ProviderAlias(...)]to its own provider compiles with warnings as errors and binds to the public attribute. If an Abstractions 10.x+ reference ever reached net8.0/net9.0, the compiler would flag the duplicate (CS0436), whichCompono.Logging.csprojpromotes to an error, instead of changing behavior silently. - Native AOT: that consumer, published with
PublishAot=truefor net8.0 and net9.0, produced no IL2xxx/IL3xxx warnings and honored the alias at runtime. net10.0 keeps its existingCompono.Logging.AotSmokeTestcoverage.
Public API, dependency graph, and every other decision in this ADR are unchanged. Documentation no longer carries a net8/net9 alias caveat.
Links¶
- Issue #164; parent #161
- ADR-0055 (and its Amendment 6)
- ADR-0022 Amendment 4, RESEARCH-0015 (non-owning disposal)
- RESEARCH-0013, RESEARCH-0033, RESEARCH-0036
- PLAN-0072