Kanject.Core.Features
3.13.1
Prefix Reserved
dotnet add package Kanject.Core.Features --version 3.13.1
NuGet\Install-Package Kanject.Core.Features -Version 3.13.1
<PackageReference Include="Kanject.Core.Features" Version="3.13.1" />
<PackageVersion Include="Kanject.Core.Features" Version="3.13.1" />
<PackageReference Include="Kanject.Core.Features" />
paket add Kanject.Core.Features --version 3.13.1
#r "nuget: Kanject.Core.Features, 3.13.1"
#:package Kanject.Core.Features@3.13.1
#addin nuget:?package=Kanject.Core.Features&version=3.13.1
#tool nuget:?package=Kanject.Core.Features&version=3.13.1
Kanject.Core.Features
Kanject Features — editioning, pruning, and runtime gates.
Kanject Features lets one source tree produce different product artifacts: Free, Pro, Enterprise, customer-specific, offline, regional, trial, internal, and white-label builds. You declare the lock once, on the capability, and Kanject wires the edition constants, service registrations, prune points, runtime gates, diagnostics, and publish checks.
The mental model is small:
Edition locks decide what ships. Runtime flags decide what runs.
An edition value such as [LockedBehind(Edition.Pro)] is a build-time fact, so Kanject
can omit registrations, elide prune points, omit locked references, and verify the lower
artifact does not contain code marked as pruned. A string flag such as
[LockedBehind("beta.export")] is a runtime fact, so Kanject routes the call through
IFeatureGate for betas, kill switches, and entitlements.
Boundary: this is distribution hygiene and consistency, not DRM. Compile-time locking protects code you did not ship; runtime checks in a shipped binary are still bypassable by whoever owns the machine.
This package is the runtime half: IFeatureGate, the stock in-memory and delegate gates,
FeatureKey, the DI registration helpers, and FeatureLockedException. The source
generators, analyzers, and MSBuild targets ship in Kanject.Core.Features.Annotations.
Installation
Reference the runtime and the generator package together:
<ItemGroup>
<PackageReference Include="Kanject.Core.Features" />
<PackageReference Include="Kanject.Core.Features.Annotations" PrivateAssets="all" />
</ItemGroup>
or from the command line:
dotnet add package Kanject.Core.Features
dotnet add package Kanject.Core.Features.Annotations
dotnet add package writes an IncludeAssets line for Kanject.Core.Features.Annotations
that omits compile. Delete that line (keep PrivateAssets="all") — otherwise the attribute
types from Kanject.Core.Features.Annotations.Attributes can be stripped from compilation.
Targets .NET 8, .NET 9 and .NET 10. The runtime package is Native AOT / trimming compatible.
The attributes ([EditionLadder], [LockedBehind], [PrunedBelow], …) live in the
Kanject.Core.Features.Annotations.Attributes package, which Kanject.Core.Features.Annotations
depends on, so you normally never reference it yourself. Always consume the generator as a
PackageReference — see Assembly boundaries and build rules.
What can you build?
Use Features when the same codebase needs different capability sets:
- Free / Pro / Enterprise desktop apps
- CLI tools with paid commands
- open-core libraries and commercial SDKs
- customer-specific enterprise builds
- white-label partner builds
- offline or air-gapped variants
- region-specific compliance builds
- internal builds with diagnostics or experimental engines
- trial builds with shipped upsell UI but absent premium engines
- beta features, kill switches, and signed entitlements for code that must ship
For example, a desktop app can publish two artifacts from the same project:
dotnet publish -p:KanjectEdition=Free -p:PublishTrimmed=true -o ./dist/free
dotnet publish -p:KanjectEdition=Pro -p:PublishTrimmed=true -o ./dist/pro
The Free artifact can keep the shared app shell and upsell UI while omitting the Pro implementation. The Pro artifact includes and registers the Pro implementation.
Why not maintain separate Free and Pro codebases?
Separate source trees look simple at first, but they drift:
- bug fixes must be ported twice
- shared UI and domain behavior diverge
- tests and release pipelines split
- edition differences become tribal knowledge
- one product eventually lags behind the other
Features keeps one source tree as the source of truth. Shared code stays shared; premium behavior is isolated behind edition locks, prune points, locked references, or runtime gates.
Use separate codebases only when the editions are truly different products. If they are the same product with different capabilities, one codebase plus edition locks is usually the cleaner architecture.
Which lock should I use?
If you are unsure, start here:
- Use an edition service lock for whole capabilities with their own service.
- Use an edition partial prune point for small premium contributions inside shared code.
- Use a runtime partial method gate for shipped code that should run only when a flag, entitlement, or kill switch allows it.
- Use locked references for the strongest artifact boundary.
- Use interceptor mode only for existing non-partial code you cannot reshape yet.
| Need | Use |
|---|---|
| Build different SKUs from one source tree | <KanjectEdition> + [EditionLadder] |
| Omit a premium service | [LockedBehind(Edition.Pro)] on the service class |
| Prune woven contribution code | edition-locked elidable partial void method |
| Strongest module isolation | edition-conditional <LockedReference> to a project/package |
| Gate a beta, rollout, or kill switch | [LockedBehind("flag")] + IFeatureGate |
| Ship a premium feature present but gated | [LockedBehind(Edition.Pro, Binding = FeatureBinding.Runtime)] |
| Gate code that must ship on a signed license | an entitlement-backed IFeatureGate |
| Prove code is absent | [PrunedBelow(...)] + <VerifyEditionPruning> |
| Keep lower editions internally consistent | KANFL003 edition graph analysis |
Integration effort
Features can be introduced incrementally. You do not need to edition the whole product in one pass.
| Codebase shape | Typical effort | Recommended path |
|---|---|---|
| New code with clean service boundaries | Low | Define [EditionLadder], lock premium services, call AddLockedFeatures(), add two CI test passes. |
| Existing code with clear premium services | Low to Medium | Lock the service classes first, fix any KANFL003 dependency graph warnings, then add pruning verification for must-be-absent code. |
| Existing code with premium logic woven into shared methods | Medium | Extract premium contributions behind edition partial prune points; keep shared orchestration unchanged. |
| Existing shipped feature that must remain present but gated | Medium | Use runtime partial method gates or an entitlement-backed IFeatureGate; add fallbacks for graceful disabled behavior. |
| Existing non-partial methods that are hard to reshape | Medium to High | Use interceptor mode as a migration tool, then move stable locks to the partial method form when practical. |
| Strong artifact isolation or customer-specific modules | Medium to High | Move premium/customer code into separate projects/packages and use <LockedReference>. |
For an AI agent or developer integrating this into an existing codebase, the safest workflow is:
- Inventory capabilities and choose the edition ladder.
- Classify each capability as absent below an edition, present but gated, or always present.
- Prefer service locks for whole capabilities.
- Use partial prune points for small premium contributions inside shared code.
- Use runtime partial gates for shipped code controlled by flags or entitlements.
- Use interceptor mode only as a migration bridge for existing non-partial methods.
- Build, fix the
KANFL0xxdiagnostics, then add lowest/highest edition CI passes. - Add
[PrunedBelow]and publish verification only after the code is isolated enough for absence to be meaningful.
Decision tree
Do you need the code absent from lower-edition artifacts?
├─ Yes
│ ├─ Is it a whole service/capability with a DI boundary?
│ │ └─ Use [LockedBehind(Edition.X)] on the service + services.AddLockedFeatures().
│ ├─ Is it a small contribution inside shared orchestration?
│ │ └─ Use an edition-locked elidable partial void + {Method}Core.
│ ├─ Is it a large module, customer integration, or sensitive implementation?
│ │ └─ Move it to a separate project/package and use <LockedReference>.
│ └─ Must CI prove it is absent after publish?
│ └─ Add [PrunedBelow(...)] + <VerifyEditionPruning>true</VerifyEditionPruning>.
│
└─ No, the code may ship but should not always run
├─ Can you shape the entry point as a partial method?
│ └─ Use [LockedBehind("flag")] partial method + {Method}Core + IFeatureGate.
├─ Is the gate tied to an edition entitlement but code must ship?
│ └─ Use [LockedBehind(Edition.X, Binding = FeatureBinding.Runtime)].
├─ Is the code already non-partial and expensive to reshape?
│ └─ Use interceptor mode temporarily; avoid generic/static/method-group/unsupported call forms.
└─ Does disabled behavior need to be graceful?
└─ Add Fallback = nameof(...) with a signature-compatible fallback method.
Using Features with Packages
Features and the Kanject.Core.Packages package solve different parts of the same product
architecture problem:
Packages decide which provider handles a port.
Features decide whether that provider/capability ships or runs.
Use them together when a package provider is edition-, customer-, region-, rollout-, or entitlement-specific. Do not combine them just to add an on/off check around one provider; use Features alone for that. The value appears when the same port has multiple providers and not every provider should be available in every artifact or runtime context.
| Scenario | Packages provides | Features adds |
|---|---|---|
| Free/Pro providers | IPackageScope routes a port to basic, stripe, enterprise, etc. |
Premium providers live in assemblies lower editions don't reference, or gate their operations at runtime. |
| Customer-specific integrations | One package interface with customer/provider implementations. | Customer builds reference only the integration assemblies they bought. |
| Regional or compliance variants | Per-request scope selects the regional provider. | Locked references keep disallowed regional providers out of other artifacts. |
| Provider beta rollout | Scope pins a provider key/version. | Runtime flags or entitlements decide whether the beta provider can run. |
| Enterprise connector marketplace | Packages expose a stable dispatch API. | Editions and entitlements control which connectors are installed, visible, or callable. |
| Startup health | ValidatePackageGraph() proves registered package providers resolve. |
Run validation per edition so a scope or default never points at a provider that edition doesn't ship. |
The Packages generator does not read
[LockedBehind]. A provider's Packages registration comes from its generatedAdd…Packages()extension and is emitted in every edition. An edition[LockedBehind(Edition.Pro)]on a provider class therefore does not remove the provider from a Free build — andAddLockedFeatures()would additionally register the class as a plain (non-keyed) service for each interface it directly implements. Don't put an edition lock on a provider class.
To make a provider absent below an edition, put it in its own assembly and include that
assembly with <LockedReference> (see Omit premium assemblies),
then validate the package graph per edition.
To ship a provider but restrict what it does, gate its operations at runtime. For example,
a Stripe v2 provider that is present in every artifact but only captures payments when the
Edition.Pro feature is enabled:
using Kanject.Core.Features.Annotations.Attributes;
using Kanject.Core.Features.Annotations.Attributes.Enums;
using Kanject.Core.Features.Interfaces;
using Kanject.Core.Packages.Annotations.Attributes;
[Package<IPaymentGateway>]
[PackageProvider(id: "stripe.payment.gateway", version: 2)]
[PackageResolverKey("stripe")]
public sealed partial class StripeV2PaymentGateway : IPaymentGateway
{
private readonly IFeatureGate _gate;
public StripeV2PaymentGateway(IFeatureGate gate) => _gate = gate;
[LockedBehind(Edition.Pro, Binding = FeatureBinding.Runtime, Fallback = nameof(CaptureLocked))]
public partial Task<Receipt> CaptureAsync(Order order);
private Task<Receipt> CaptureAsyncCore(Order order) => /* Pro implementation */;
private Task<Receipt> CaptureLocked(Order order) => Task.FromResult(Receipt.Declined);
}
Application code still chooses the provider for a unit of work through Packages:
var packageScope = PackageScopeBuilder.Create()
.Use<IPaymentGateway>("stripe", version: 2)
.Build();
When the provider is enabled by a server-signed license rather than by build edition, back
IFeatureGate with an entitlement gate (see Runtime gates and entitlements).
Combined decision tree
Does this capability have multiple interchangeable implementations?
├─ No
│ └─ Use Features alone.
│
└─ Yes
├─ Should callers choose a provider per request, tenant, order, or command?
│ └─ Use Packages and IPackageScope.
│
├─ Are some providers unavailable in lower editions or customer builds?
│ └─ Add Features.
│ ├─ Provider must be absent? Move it to its own assembly and use <LockedReference>.
│ ├─ Provider may ship but needs a license? Gate its methods at runtime / IFeatureGate.
│ └─ Provider is in rollout? Use a runtime flag plus a package version pin.
│
└─ Can a lower edition's package scope/default still point at a removed provider?
└─ Run ValidatePackageGraph() for each edition in CI or startup checks.
Do I need Native AOT?
No. Features is useful in normal .NET builds for SKU wiring, generated gates, fewer hand-written checks, runtime flag consistency, and build-time diagnostics.
AOT and trimming matter when you want the stronger guarantee: lower-edition artifacts do not contain locked code. For actual absence, use one or more of these:
PublishTrimmed=trueor Native AOT publishing- premium logic isolated behind generated prune points
- premium services whose registrations are omitted and whose types become unreferenced
- premium code isolated in assemblies omitted from lower editions
- post-publish pruning verification
In an untrimmed same-assembly build, generated registrations and call paths can be absent, but unused premium types may still exist in the DLL. Use trimming, AOT, or locked references when artifact absence matters.
Security posture
Features improves security only when it helps you not ship code. It does not turn a client-side runtime check into DRM, anti-tamper, or server-side authorization.
The strongest guarantee is absence:
Code not present in the artifact cannot be called, patched, reflected into, or reverse-
engineered from that artifact.
Use this stack when code must be absent from lower editions:
- Isolate the implementation behind an edition service lock, edition prune point, or
<LockedReference>. - Publish lower editions with trimming or Native AOT.
- Add
[PrunedBelow(...)]to code that must be absent. - Enable
<VerifyEditionPruning>true</VerifyEditionPruning>in CI/publish. - Run the lowest and highest edition builds as separate release gates.
What AOT honestly adds
Native AOT can improve security posture by strengthening artifact minimization. When premium code is unreachable in a lower edition, AOT/trimming can remove the implementation, metadata, and reflection surface from the published artifact. A native artifact is also less transparent than IL.
AOT does not make shipped code tamper-proof. If a runtime gate, entitlement verifier, or premium implementation is still present in the binary, a user who controls that machine can patch, bypass, or call it another way. AOT helps with absence, not with making present code unbreakable.
Runtime gates and entitlements
Runtime gates are a policy seam for code that intentionally ships:
- beta flags
- kill switches
- in-app upsell paths
- customer entitlements
- code shared across editions
They centralize decisions and produce consistent behavior, but they are not a hard security boundary in a client-owned artifact. Offline entitlement verification prevents token forgery without the private key, but the public key and verifier live in the client binary and can still be patched by the machine owner.
For code that must ship but should run only for a licensed customer, the commercial
Kanject.Core.Features.Entitlements package (not on nuget.org) provides
EntitlementFeatureGate — an IFeatureGate built from an ECDSA-signed entitlement token
that denies every feature when the token fails verification and starts denying the moment
the entitlement expires. Typed license claims can be read with the [EntitlementClaims]
generator (see Kanject.Core.Features.Annotations).
For high-value operations, enforce authorization server-side. Use runtime gates for local UX, consistency, and defense-in-depth — not as the only control.
Interceptor mode security boundary
Interceptor mode gates direct call sites. It is useful as a migration bridge for existing
non-partial code, but it is not equivalent to a method-body guard. Statically visible escapes
— method-group captures (var render = dashboard.Render) and the unsupported call forms — are
reported as KANFL004. Reflection, dynamic, and other fully indirect invocations reach a
method without an interceptable call site and cannot be detected at all.
Use the partial method form when a runtime lock must apply to every route into the method:
[LockedBehind("premium.export", Fallback = nameof(Locked))]
public partial ExportResult Export(Project project);
private ExportResult ExportCore(Project project) => /* premium implementation */;
private ExportResult Locked(Project project) => ExportResult.Disabled;
Because the generated gate lives in the method body, direct calls, delegate calls, reflection, and dynamic invocation all enter through the same guard.
Security checklist
| Concern | Recommended pattern |
|---|---|
| Lower edition must not contain premium code | <LockedReference> or edition lock + trim/AOT + [PrunedBelow] verification |
| Premium provider must be absent | Put the provider in a locked project/package, then validate the package graph per edition |
| Feature may ship but needs license | Runtime partial gate + entitlement-backed IFeatureGate |
| Disabled path must be safe | Add an explicit fallback and test disabled behavior |
| Existing non-partial code needs temporary gating | Interceptor mode, with documented call-form limits |
| High-value action must be protected | Server-side authorization; client Features are not enough |
| Feature keys reveal sensitive strategy | Use stable non-secret keys; treat feature names as public metadata |
| Offline expiry matters | Prefer server refresh/checks for valuable capabilities; local clocks can be manipulated |
The pieces
| Piece | Responsibility |
|---|---|
[EditionLadder] |
Marks the enum that is your SKU ladder; member declaration order = unlock order. |
<KanjectEdition> |
Selects the build edition for this artifact. |
Editions.Has* |
Generated compile-time constants for edition-aware branches. |
[LockedBehind(...)] |
Locks a class or method behind an edition or runtime flag. |
[PrunedBelow(...)] |
Marks a type/member that must be absent below an edition. |
<LockedReference> |
Includes a project/package reference only when the selected edition unlocks it. |
IFeatureGate |
Runtime seam for string-flag and runtime-bound locks: betas, entitlements, kill switches. |
| Generators | Emit edition constants, AddLockedFeatures(), method guards, prune points, and interceptors. |
| Analyzers / MSBuild targets | Validate ladders, dependency graphs, locked shapes, references, and publish artifacts. |
Usage
Quick start
For the common Free/Pro shape:
using Kanject.Core.Features.Annotations.Attributes;
[EditionLadder]
public enum Edition
{
Free,
Pro,
}
[LockedBehind(Edition.Pro)]
public sealed class ProExportEngine : IExportEngine
{
public ExportResult Export(Project project) => /* paid implementation */;
}
services.AddLockedFeatures();
dotnet publish -p:KanjectEdition=Free -p:PublishTrimmed=true -o ./dist/free
dotnet publish -p:KanjectEdition=Pro -p:PublishTrimmed=true -o ./dist/pro
The Pro build registers ProExportEngine; the Free build omits that registration. With
trimming/AOT and no other references, the implementation can be absent from the Free
artifact.
1. Define the edition ladder
Declare the ladder once. Member declaration order is the unlock order, so Pro includes
everything available in Free, and Enterprise includes both. Exactly one enum per
assembly may carry [EditionLadder] (KANFL005).
[EditionLadder]
public enum Edition
{
Free,
Pro,
Enterprise,
}
Pick the edition at build time:
<PropertyGroup>
<KanjectEdition>Pro</KanjectEdition>
</PropertyGroup>
The generator emits sibling compile-time constants in the enum's namespace (with the enum's accessibility):
public static class Editions
{
public const Edition Current = Edition.Pro;
public const bool HasFree = true;
public const bool HasPro = true;
public const bool HasEnterprise = false;
}
Branch on them directly — a false branch is dead code the trimmer and Native AOT compiler
remove:
if (Editions.HasPro)
{
menu.Add(new AdvancedExportCommand());
}
The compiler's "unreachable code" warning (CS0162) on such a branch is suppressed
automatically, so the idiom is safe under TreatWarningsAsErrors.
When <KanjectEdition> is not set, the lowest edition is selected — the safe default never
accidentally unlocks. An out-of-ladder value (matched case-insensitively) raises
KANFL006 and falls back to the lowest edition.
2. Lock a service behind an edition
Use edition locks for code that should only ship in higher artifacts:
[LockedBehind(Edition.Pro)]
public sealed class AdvancedExportEngine : IExportEngine
{
public ExportResult Export(Project project) => /* premium implementation */;
}
Call the generated extension once at startup. It is emitted into your project's root
namespace, in a class named LockedFeatureServiceCollectionExtensions:
services.AddLockedFeatures();
In a Pro build, AdvancedExportEngine is registered as itself and as each interface it
directly implements (IExportEngine), using TryAdd* so an earlier registration wins. In a
Free build, those registration lines are omitted. If the type is otherwise unreferenced and
the app is trimmed/AOT-published, the type can be removed from the artifact. The
KANFL003 analyzer flags lower-edition consumers that constructor-inject higher-edition
services.
Lifetime defaults to Scoped:
using Kanject.Core.Features.Annotations.Attributes.Enums;
[LockedBehind(Edition.Pro, Lifetime = LockedServiceLifetime.Singleton)]
public sealed class AdvancedExportEngine : IExportEngine { }
A string flag on a class ([LockedBehind("beta")]) is rejected with KANFL009 — runtime
flags gate methods, not whole registrations.
3. Prune woven code with an edition partial
Some premium behavior is not a clean DI service. It may be a contribution inside a planner, analyzer, formatter, or other shared pipeline. For that shape, use an elidable edition partial:
public partial class QueryPlanner
{
[LockedBehind(Edition.Pro)]
partial void ContributeProRules(RuleSet rules);
public RuleSet BuildRules()
{
var rules = new RuleSet();
ContributeProRules(rules);
return rules;
}
private void ContributeProRulesCore(RuleSet rules)
{
rules.Add(new AdvancedJoinRewriteRule());
}
}
In editions that unlock the method, Kanject emits the implementing partial that calls the real premium contribution. In lower editions, Kanject emits no implementation, so the C# compiler elides the call site and its argument evaluation.
True elision is intentionally narrow. The shape must be:
- an implicitly private
partial void— no accessibility orvirtual/override/sealed/new/extern/async/abstractmodifier — with nooutparameters (otherwiseKANFL007); - an instance method on a non-generic, non-nested, non-static class;
- backed by an instance
{Method}Corewhose signature matches — a static{Method}Coreis reported asKANFL004, not bound.
Generic methods (with their constraints) and ref/in parameters are supported when the
matching {Method}Core signature is compatible. Non-elidable or mismatched shapes are
rejected by diagnostics so a lock never silently turns into a no-op.
public partial class Planner
{
[LockedBehind(Edition.Pro)]
partial void Contribute<T>(List<T> rules) where T : class;
private void ContributeCore<T>(List<T> rules) where T : class
{
rules.Add(/* premium rule */);
}
}
Stateless seams and TreatWarningsAsErrors. Because {Method}Core must be an instance
method, a seam that touches no instance state trips CA1822 ("mark members as static") on
both the partial declaration and {Method}Core, and IDE0051 (unused private member) on
{Method}Core in editions below the lock, where nothing calls it. Suppress those three on
the seam — it is the documented cost of the shape:
public partial class ReportBuilder
{
#pragma warning disable CA1822, IDE0051 // edition prune seam: instance by contract, unreferenced below Pro
[LockedBehind(Edition.Pro)]
partial void AddForecastSection(List<string> sections);
private void AddForecastSectionCore(List<string> sections) => sections.Add("forecast");
#pragma warning restore CA1822, IDE0051
}
4. Omit premium assemblies
For the strongest boundary, isolate premium code in a separate project or package and make the reference edition-conditional. Lower editions never reference the assembly, so there is nothing for the trimmer to prove.
MSBuild resolves references before it can read the [EditionLadder] enum, so restate the
ladder, in declaration order, as <KanjectEditionLadder>:
<PropertyGroup>
<KanjectEditionLadder>Free;Pro;Enterprise</KanjectEditionLadder>
</PropertyGroup>
<ItemGroup>
<LockedReference Include="..\App.Pro\App.Pro.csproj" MinEdition="Pro" />
<LockedReference Include="Acme.Enterprise.Connectors" ReferenceType="Package" Version="1.2.3" MinEdition="Enterprise" />
</ItemGroup>
A <LockedReference> becomes a real ProjectReference (or, with ReferenceType="Package",
a PackageReference) only when the build's <KanjectEdition> reaches its MinEdition.
The build fails loudly on a misconfigured reference: KANFL911 when <KanjectEditionLadder>
is missing, KANFL912 when MinEdition isn't on it, and KANFL008 when the MSBuild ladder
disagrees with the enum.
Code in the referencing project cannot name types from a locked assembly unconditionally —
below MinEdition the reference doesn't exist. Use this for large paid modules,
customer-specific integrations, or code you want categorically absent from lower artifacts.
5. Verify pruning after publish
Mark code that must be absent below an edition:
[PrunedBelow(Edition.Pro)]
public sealed class AdvancedExportEngine { }
[PrunedBelow] applies to classes, structs, enums, interfaces, methods, properties, and
fields. With pruning verification enabled, Kanject inspects the publish output and fails the
publish (KANFL921) if a lower-edition artifact still contains a marked type/member, if an
unlocking artifact unexpectedly lost it, or if the artifact can't be read (fail-closed).
<PropertyGroup>
<VerifyEditionPruning>true</VerifyEditionPruning>
</PropertyGroup>
- Verification runs after a trimmed publish (
PublishTrimmed) or a Native AOT publish. For Native AOT also set<IlcGenerateMstatFile>true</IlcGenerateMstatFile>; the AOT backend verifies non-generic types only, so verify member-level and generic markers against a trimmed artifact. - Enabling verification with no
[PrunedBelow]markers fails the build, so a misplaced marker can't pass silently. Set<KanjectRequirePrunedBelowMarkers>false</KanjectRequirePrunedBelowMarkers>to allow an empty run. - Verification is single-project — see Assembly boundaries and build rules.
This turns "the trimmer should remove it" into a CI-checkable artifact property.
6. Gate runtime features
A string argument means "decide at runtime." Put the real logic in {Method}Core, declare
the entry method as partial, and give the type an IFeatureGate field or property:
using Kanject.Core.Features.Annotations.Attributes;
using Kanject.Core.Features.Interfaces;
public sealed partial class Dashboard
{
private readonly IFeatureGate _gate;
public Dashboard(IFeatureGate gate) => _gate = gate;
[LockedBehind("beta.export", Fallback = nameof(Locked))]
public partial string Render();
private string RenderCore() => "high-res export";
private string Locked() => "Upgrade to export.";
}
Wire a gate:
using Kanject.Core.Features.Extensions;
services.AddInMemoryFeatureGate("beta.export");
// or: services.AddFeatureGate(key => myRemoteFlags.IsOn(key.Name));
// or: services.AddFeatureGate<MyEntitlementGate>();
Each helper registers a singleton IFeatureGate with TryAdd, so the first registration
wins. Runtime gates are the right tool for betas, gradual rollout, emergency kill switches,
in-app upsell surfaces, and entitlements for code that must ship.
The method shape is deliberately checked:
- The containing type must be a non-static, non-generic, non-nested class.
- The locked method must be an instance
partialmethod. - The containing type (or a base class) needs an
IFeatureGatefield or property. {Method}Coremust exist as an instance method and match the locked method signature.Fallback, when declared, must be an instance method matching the locked method signature.- Generic method type parameters,
whereconstraints, andref/inparameters are preserved. outparameters are rejected on the partial guard path; return a result object/value instead, or gate a service.
When the feature is disabled and no fallback is declared, the generated guard throws
FeatureLockedException (namespace Kanject.Core.Features.Diagnostics.Exceptions), whose
Code is KANFL050 and whose Feature names the locked feature.
Present but gated. To ship an edition-locked capability in every artifact but gate
it at runtime instead of pruning it (an in-app upsell, an entitlement, a kill switch), set
Binding = FeatureBinding.Runtime. The edition then behaves like a string flag keyed on
"{EnumName}.{Member}" (e.g. "Edition.Pro"):
[LockedBehind(Edition.Pro, Binding = FeatureBinding.Runtime)]
public partial string RenderUpsell(); // present in Free, gated through IFeatureGate on "Edition.Pro"
On a service class, Binding = FeatureBinding.Runtime registers it unconditionally (it is
present in every edition; KANFL003 then leaves it alone). For code that must ship, back
the gate with a signed entitlement — see Runtime gates and entitlements.
7. Interceptor mode
<EnableFeatureLockInterceptors>true</EnableFeatureLockInterceptors> lets you write a
normal method with a real body; the generator intercepts call sites instead of requiring a
partial/Core split. The locked method, its IFeatureGate member, and its fallback must
all be non-private because the interceptor lives in a separate generated class.
Experimental. Interceptor mode uses Roslyn's versioned
InterceptableLocationencoding, so generated source is independent of the absolute checkout path. Prefer the partial method form unless you need to gate existing non-partial code; the receiver/call-shape restrictions below still apply.
Interceptor mode is intentionally stricter than the partial method form. It only supports locked instance methods called through an explicit, non-conditional receiver:
dashboard.Render(); // supported
Render(); // rejected: no explicit receiver
dashboard?.Render(); // rejected: conditional receiver
Generic methods, static methods, unsupported call forms, method-group captures
(var render = dashboard.Render), private locked methods / gate members / fallbacks, and
signature-mismatched fallbacks are reported as KANFL004/KANFL002 instead of being silently
ignored. Those are the statically detectable escapes; reflection and dynamic reach a
method with no call site to rewrite and cannot be detected at all. If you want every call
form to be safe — or a locked method needs to be passed as a delegate — use the partial
method form, whose gate lives in the method body.
Assembly boundaries and build rules
These rules can't be inferred from any one attribute, and getting them wrong typically means re-planning a project layout rather than fixing one line.
The compile-time prune is assembly-local to the [EditionLadder]. The generators only
see the ladder enum declared in the same compilation. Both the [LockedBehind(Edition.X)]
partial-void prune and the [PrunedBelow] manifest require it: with no ladder in the
assembly, the prune reports KANFL004 and the manifest reports KANFL001, and neither emits
anything. The same holds for edition-locked service classes (KANFL001, not registered), and
the KANFL003 graph analysis only runs in the ladder's assembly. Put prunable code in the
ladder's assembly. To gate code in other assemblies, branch on the generated
Editions.HasX constant: make the ladder enum public (the Editions class takes the enum's
accessibility) and build every project with the same -p:KanjectEdition=.... A const
folds into — and trims out of — the referencing assembly through project references; the
attributes don't cross assemblies. String-flag and Binding = FeatureBinding.Runtime method
locks don't need the ladder at all.
The partial-void seam binds to an instance {Method}Core. A static {Method}Core is a
KANFL004 error. A stateless seam then needs CA1822 and IDE0051 suppressed under
TreatWarningsAsErrors (see section 3).
VerifyEditionPruning is single-project. The verification target reads the manifest
generated in the publishing project and inspects that project's own published assembly (or
its .mstat for Native AOT). The ladder, the [PrunedBelow] markers, the generator, and the
trimmed/AOT publish must all live in one project; markers in a referenced library are not
verified (and, with no local markers, verification fails rather than passing vacuously).
Consume the generator as a package. Kanject.Core.Features.Annotations ships
build/ props that make <KanjectEdition> (and the other switches) visible to the
compiler, plus the targets for locked references, the publish guard, and verification. Wire
it any other way — a ProjectReference or a bare <Analyzer> item — and those files aren't
imported: <KanjectEdition> is never seen, and the generators silently build the lowest
edition. If you must, import the package's .props and .targets yourself.
Artifact guarantees
| Binding | Decided | Mechanism | Hot path | Code in lower artifact? |
|---|---|---|---|---|
| Edition service | Build | omitted registration + trim/AOT | zero | absent when unreferenced and trimmed/AOT |
| Edition partial prune point | Build | no implementation in lower editions | zero | absent from generated/call-site IL |
| Locked reference | Build | assembly not referenced | zero | absent |
Editions.Has* branch |
Build | const bool branch folding |
zero | absent when dead code becomes unreferenced and trimmed/AOT |
| String flag / runtime-bound edition | Run | IFeatureGate.IsEnabled |
one call + branch | present, intentionally gated |
Diagnostics
Prefix KANFL: compile-time 001–049, runtime 050+, MSBuild build/publish and pruning
verification in the 9xx band.
| Code | When | Severity |
|---|---|---|
KANFL001 |
A [LockedBehind] service or [PrunedBelow] element uses an edition that can't be ranked (no [EditionLadder] in the assembly, a different enum, or an unknown member) |
Error |
KANFL002 |
A [LockedBehind] fallback is missing or signature-mismatched |
Error |
KANFL003 |
A consumer reachable at edition E injects a capability provided only above E | Warning |
KANFL004 |
A [LockedBehind] method can't be guarded, pruned, or intercepted (shape, missing {Method}Core/gate member, no ladder, unsupported call form, method group) |
Error |
KANFL005 |
More than one [EditionLadder] enum in the assembly |
Error |
KANFL006 |
<KanjectEdition> names a value not on the ladder |
Error |
KANFL007 |
An edition-locked partial method cannot be elided | Error |
KANFL008 |
<KanjectEditionLadder> disagrees with the [EditionLadder] enum |
Error |
KANFL009 |
A [LockedBehind] lock applied in an unsupported way (e.g. a string flag on a class) |
Error |
KANFL010–KANFL015 |
An [EntitlementClaims] type or [Claim] property has an unsupported shape |
Error |
KANFL050 |
A locked runtime method was invoked while disabled with no fallback (FeatureLockedException) |
Runtime exception |
KANFL910 |
RequireExplicitEditionOnPublish is set but the publish chose no edition |
Error |
KANFL911 / KANFL912 |
A <LockedReference> with no <KanjectEditionLadder> / an off-ladder MinEdition |
Error |
KANFL921 |
Pruning verification failed — a marked type/member is present where it should be absent (or absent where it should be present), or the artifact is missing/unreadable (fail-closed) | Error |
The full per-ID list, including the CS0162 suppression, is in the
Kanject.Core.Features.Annotations package README.
How to respond to common diagnostics
| Code | What to do |
|---|---|
KANFL001 |
Declare the [EditionLadder] enum in the same assembly as the locked/pruned code, and use one of its members. |
KANFL002 |
Make Fallback an instance method with the same return type, generic arity/constraints, parameter types, and ref/in/out modifiers as the locked method. |
KANFL003 |
Either raise the consumer's required edition, lower the dependency's edition, or introduce a lower-edition implementation of the dependency. |
KANFL004 |
For runtime method gates, prefer the partial method pattern with an instance {Method}Core; for interceptor mode, call through an explicit receiver and avoid generic/static methods and method groups. |
KANFL007 |
Keep edition prune points as implicitly private partial void methods with no out parameters, or set Binding = FeatureBinding.Runtime. |
KANFL050 |
Enable the feature in IFeatureGate, or add a Fallback method for graceful disabled behavior. |
Release safety and testing
For product builds, enable the publish guard so release artifacts must choose an edition
explicitly — on the command line (-p:KanjectEdition=), as a global/environment property, or
in Directory.Build.props. A value in the project file body doesn't count, and a publish
without an explicit edition fails with KANFL910:
<PropertyGroup>
<RequireExplicitEditionOnPublish>true</RequireExplicitEditionOnPublish>
</PropertyGroup>
For tests, build and run at least two passes, each with its own edition:
- highest edition for full correctness coverage
- lowest edition for gating, pruning, and absence verification
# Pass 1 — full edition: correctness coverage
dotnet test -p:KanjectEdition=Enterprise
# Pass 2 — lowest edition: gating + absence
dotnet test -p:KanjectEdition=Free
Assert both passes ran; green on one edition is not green.
The commercial Kanject.Core.Features.TestKit package (not on nuget.org) adds xUnit
[EditionFact] / [EditionTheory] attributes that skip a test when the run's edition — read
from the KANJECT_EDITION environment variable — does not unlock the capability under test.
When using it, set KANJECT_EDITION to the same value as KanjectEdition in each pass.
What this is not
- Not DRM or anti-tamper. A runtime check in a shipped binary is bypassable by the machine owner. The hard guarantee is absence from artifacts you chose not to ship.
- Not a replacement for architecture. Premium code still needs to be isolated into prunable units: services, elidable partial contributions, or omitted assemblies.
- Not a mandate to use AOT. AOT/trimming is how you get the strongest absence guarantee; the generated SKU wiring, diagnostics, and runtime gates are useful without it.
Public surface at a glance
| Type / member | Purpose |
|---|---|
IFeatureGate (Kanject.Core.Features.Interfaces) |
bool IsEnabled(FeatureKey feature) — the runtime seam generated guards call. |
FeatureKey (Kanject.Core.Features.Models) |
Allocation-free readonly record struct over a feature name; implicit from string. |
InMemoryFeatureGate (Kanject.Core.Features.Gates) |
Enables exactly a fixed set of feature names (ordinal). |
DelegateFeatureGate (Kanject.Core.Features.Gates) |
Delegates the decision to a Func<FeatureKey, bool>. |
AddInMemoryFeatureGate(params string[]) |
Registers an InMemoryFeatureGate singleton. |
AddFeatureGate(Func<FeatureKey, bool>) / AddFeatureGate<TGate>() |
Registers a delegate gate or your own IFeatureGate singleton. |
FeatureLockedException (Kanject.Core.Features.Diagnostics.Exceptions) |
Thrown by a disabled guard with no fallback; exposes Feature and Code. |
FeatureLockCodes.FeatureLocked (Kanject.Core.Features.Diagnostics) |
The stable runtime code "KANFL050". |
The registration helpers live in Kanject.Core.Features.Extensions
(FeatureGateServiceCollectionExtensions).
Related packages
| Package | Role | Availability |
|---|---|---|
Kanject.Core.Features.Annotations |
Source generators, analyzers, and MSBuild targets (edition constants, AddLockedFeatures(), guards, prune points, locked references, pruning verification). |
nuget.org |
Kanject.Core.Features.Annotations.Attributes |
The attribute and enum types ([EditionLadder], [LockedBehind], [PrunedBelow], …). |
nuget.org |
Kanject.Core.Packages |
Provider dispatch for ports; combine with Features for edition-specific providers. | nuget.org |
Kanject.Core.Features.Entitlements |
ECDSA-signed entitlement tokens and EntitlementFeatureGate. |
Commercial license (not on nuget.org) |
Kanject.Core.Features.TestKit |
Edition-aware xUnit [EditionFact] / [EditionTheory]. |
Commercial license (not on nuget.org) |
License
Licensed under the Kanject Code Libraries License Agreement (KCLLA); the full text ships in this package as LICENSE.md. Organizations whose trailing-twelve-month gross revenue and total funding raised are each below US$250,000 may use it at no cost under the Free Tier. At or above either threshold a commercial license is required — contact commercial@kanjectbusiness.com.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 is compatible. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Kanject.Core.Features.Annotations.Attributes (>= 3.13.1)
- Microsoft.Extensions.DependencyInjection (>= 10.0.11)
-
net8.0
- Kanject.Core.Features.Annotations.Attributes (>= 3.13.1)
- Microsoft.Extensions.DependencyInjection (>= 8.0.1)
-
net9.0
- Kanject.Core.Features.Annotations.Attributes (>= 3.13.1)
- Microsoft.Extensions.DependencyInjection (>= 9.0.18)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 3.13.1 | 107 | 9/27/2026 |
| 3.13.0 | 82 | 9/27/2026 |
| 3.12.7 | 88 | 9/26/2026 |
| 3.12.6 | 119 | 9/7/2026 |
| 3.12.5 | 106 | 8/27/2026 |
| 3.12.4 | 106 | 8/22/2026 |
| 3.12.3 | 122 | 8/10/2026 |
| 3.12.2 | 106 | 8/9/2026 |
| 3.12.1 | 109 | 8/5/2026 |
| 3.12.0 | 110 | 8/5/2026 |
| 3.11.0 | 116 | 8/3/2026 |
| 3.10.5 | 111 | 7/30/2026 |
| 3.10.4 | 121 | 7/18/2026 |
| 3.10.3 | 129 | 7/13/2026 |
| 3.10.2 | 118 | 7/11/2026 |
| 3.10.1 | 113 | 7/11/2026 |
| 3.10.0 | 130 | 7/9/2026 |
| 3.9.2 | 117 | 7/9/2026 |