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
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Kanject.Core.Features" Version="3.13.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Kanject.Core.Features" Version="3.13.1" />
                    
Directory.Packages.props
<PackageReference Include="Kanject.Core.Features" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Kanject.Core.Features --version 3.13.1
                    
#r "nuget: Kanject.Core.Features, 3.13.1"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Kanject.Core.Features@3.13.1
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Kanject.Core.Features&version=3.13.1
                    
Install as a Cake Addin
#tool nuget:?package=Kanject.Core.Features&version=3.13.1
                    
Install as a Cake Tool

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:

  1. Use an edition service lock for whole capabilities with their own service.
  2. Use an edition partial prune point for small premium contributions inside shared code.
  3. Use a runtime partial method gate for shipped code that should run only when a flag, entitlement, or kill switch allows it.
  4. Use locked references for the strongest artifact boundary.
  5. 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:

  1. Inventory capabilities and choose the edition ladder.
  2. Classify each capability as absent below an edition, present but gated, or always present.
  3. Prefer service locks for whole capabilities.
  4. Use partial prune points for small premium contributions inside shared code.
  5. Use runtime partial gates for shipped code controlled by flags or entitlements.
  6. Use interceptor mode only as a migration bridge for existing non-partial methods.
  7. Build, fix the KANFL0xx diagnostics, then add lowest/highest edition CI passes.
  8. 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 generated Add…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 — and AddLockedFeatures() 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=true or 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:

  1. Isolate the implementation behind an edition service lock, edition prune point, or <LockedReference>.
  2. Publish lower editions with trimming or Native AOT.
  3. Add [PrunedBelow(...)] to code that must be absent.
  4. Enable <VerifyEditionPruning>true</VerifyEditionPruning> in CI/publish.
  5. 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 or virtual/override/sealed/ new/extern/async/abstract modifier — with no out parameters (otherwise KANFL007);
  • an instance method on a non-generic, non-nested, non-static class;
  • backed by an instance {Method}Core whose signature matches — a static {Method}Core is reported as KANFL004, 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 partial method.
  • The containing type (or a base class) needs an IFeatureGate field or property.
  • {Method}Core must 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, where constraints, and ref/in parameters are preserved.
  • out parameters 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 InterceptableLocation encoding, 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).

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 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

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