SchematicHQ.Community.Extensions.AI 0.4.0

dotnet add package SchematicHQ.Community.Extensions.AI --version 0.4.0
                    
NuGet\Install-Package SchematicHQ.Community.Extensions.AI -Version 0.4.0
                    
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="SchematicHQ.Community.Extensions.AI" Version="0.4.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="SchematicHQ.Community.Extensions.AI" Version="0.4.0" />
                    
Directory.Packages.props
<PackageReference Include="SchematicHQ.Community.Extensions.AI" />
                    
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 SchematicHQ.Community.Extensions.AI --version 0.4.0
                    
#r "nuget: SchematicHQ.Community.Extensions.AI, 0.4.0"
                    
#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 SchematicHQ.Community.Extensions.AI@0.4.0
                    
#: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=SchematicHQ.Community.Extensions.AI&version=0.4.0
                    
Install as a Cake Addin
#tool nuget:?package=SchematicHQ.Community.Extensions.AI&version=0.4.0
                    
Install as a Cake Tool

SchematicHQ.AspNetCore

ASP.NET Core integration for Schematic entitlement management.

This repo includes a number of packages that expand the official SchematicHQ.Client SDK with:

  • Automated entitlement checks & tracking for ASP.NET Core routes
  • Integration with Microsoft.Extensions.AI for usage reporting
  • Time-based trait reporting with Quartz.NET
  • DI extensions with ILogger wire-up
  • FusionCache distributed caching support
  • A test double for the gate client

Packages:

Package Purpose
SchematicHQ.Community.DependencyInjection Registers the Schematic SDK client in DI with ILoggerFactory wiring, plus a FusionCache-backed ICacheProvider, fail-open defaults and shadow mode.
SchematicHQ.Community.AspNetCore Feature gating, usage tracking, entitlement snapshots and identify middleware for ASP.NET Core (net8.0+).
SchematicHQ.Community.Extensions.AI Microsoft.Extensions.AI middleware: meter chat token usage and gate model calls behind entitlements.
SchematicHQ.Community.Extensions.Quartz Quartz.NET integration (Quartz 4, net10.0+): gate and track scheduled jobs, and run trait reports on a cron schedule.
SchematicHQ.Community.Testing FakeSchematicGateClient: canned entitlement answers, recorded calls, and a one-line swap into a test host.

Quickstart

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddSchematic(builder.Configuration["Schematic:ApiKey"]!);
builder.Services.AddSchematicAspNetCore();
builder.Services.AddSchematicFlagContextResolver<MyFlagContextResolver>();

var app = builder.Build();

app.MapGroup("api").AddSchematicFilters().MapMyEndpoints();
app.MapControllers().AddSchematicFilters();

app.Run();

Tell Schematic who is making the request by implementing a resolver:

public sealed class MyFlagContextResolver : ISchematicFlagContextResolver
{
    public ValueTask<SchematicFlagContext?> ResolveAsync(HttpContext context, CancellationToken ct)
    {
        var companyId = context.User.FindFirstValue("company_id");
        var userId = context.User.FindFirstValue(ClaimTypes.NameIdentifier);
        if (companyId is null || userId is null)
            return ValueTask.FromResult<SchematicFlagContext?>(null); // gate responds 401

        return ValueTask.FromResult<SchematicFlagContext?>(new SchematicFlagContext(
            Company: new() { ["id"] = companyId },
            User: new() { ["id"] = userId }));
    }
}

For simple cases, a delegate works instead of a resolver class: AddSchematicAspNetCore(o => o.ResolveContext = http => ...).

The SDK buffers Track/Identify events and sends them periodically.

AddSchematic registers a lifetime hook that calls Schematic.Shutdown() when the host's service provider is disposed, so events buffered at shutdown are flushed instead of lost (bounded at 10 seconds so a broken connection cannot hang shutdown).

Gating endpoints

Minimal APIs:

app.MapGet("/reports", GetReports)
   .RequireFeature("advanced-reports");              // 403 ProblemDetails when not entitled

app.MapPost("/exports", CreateExport)
   .RequireFeature("exports", track: true);          // also tracks an "exports" event on success

Controllers:

[RequireFeature("advanced-reports")]
[HttpGet("reports")]
public IActionResult GetReports() => ...;

A denied check returns RFC 7807 ProblemDetails with status 403, plus featureId and accessDeniedReason extension fields. Customize with options.OnDenied.

Tracking usage

app.MapPost("/messages", SendMessage)
   .TrackFeature("messages-sent", quantity: 1);      // controllers: [TrackFeature("messages-sent")]

Events are emitted only for successful (status < 400) responses, and a tracking failure will never fail the response. RequireFeature(..., track: true) reuses the entitlement check result, so the SDK is called once per request.

Evaluating several flags at once

A gate answers one flag per request. When an endpoint needs many answers in one go — to return the set of features a caller may use, or to branch on several entitlements inside a handler — ISchematicEntitlementSnapshotProvider evaluates a set of flags for an identity through the same client and resolver the gates use:

builder.Services.AddSchematicEntitlementSnapshots(o => o.FailurePolicy = SchematicFailurePolicy.FailOpen);

app.MapGet("/features", async (HttpContext http, ISchematicEntitlementSnapshotProvider snapshots) =>
{
    var snapshot = await snapshots.GetAsync(http, ["advanced-reports", "exports", "ai-chat"]);
    if (snapshot is null)
        return Results.Unauthorized();                                  // no identity, as the gate would answer

    return Results.Ok(new
    {
        advancedReports = snapshot.IsEntitled("advanced-reports"),
        exports = snapshot.IsEntitled("exports"),
        aiCredits = snapshot["ai-chat"].Entitlement?.CreditSettled,
    });
});

GetAsync(SchematicFlagContext, flagKeys) evaluates an identity you supply yourself. Each entry carries the value, Schematic's reason and the backing entitlement (allocation, usage, credit balance). A check that throws does not fail the snapshot: its entry takes the failure policy's value and is marked CheckFailed. The request overload returns null when no identity resolves, which is when the gates would not run either. The snapshot has no cache of its own: each check goes through the SDK, whose flag cache (see Caching with FusionCache) serves repeat checks for the same flag and identity.

Identifying customers

builder.Services.AddSchematicIdentifyContextResolver<MyIdentifyResolver>();
...
app.UseSchematicIdentify();

Calls Schematic.Identify for each request whose resolver returns an identity. Set options.IdentifyDeduplicationWindow to send at most one Identify per identity per window.

Receiving webhooks

Verify inbound Schematic webhooks with the signing secret from the dashboard:

builder.Services.AddSchematicAspNetCore(o => o.WebhookSecret = builder.Configuration["Schematic:WebhookSecret"]);
...
app.MapPost("/webhooks/schematic", (JsonElement payload) => Results.Ok())
   .RequireSchematicWebhookSignature();

The filter validates the X-Schematic-Webhook-Signature / X-Schematic-Webhook-Timestamp headers against the raw request body (via the SDK's WebhookVerifier) before the endpoint runs, responding 401 ProblemDetails when they are missing or invalid. The body remains readable by the endpoint afterwards.

Options

builder.Services.AddSchematicAspNetCore(options =>
{
    // How the gate responds when the entitlement check *throws*. FailClosed (default) => 503
    // ProblemDetails. FailOpen => request proceeds. See "Failing open" below for what an outage
    // actually does, because the SDK client does not throw.
    options.FailurePolicy = SchematicFailurePolicy.FailClosed;

    // Custom denial response.
    options.OnDenied = (http, denial) => Results.Json(new { error = denial.Reason }, statusCode: 402).ExecuteAsync(http);

    // Send at most one Identify per identity in this window (default: every request).
    options.IdentifyDeduplicationWindow = TimeSpan.FromMinutes(5);
});

Failing open

FailurePolicy only governs exceptions, and the SDK's flag check never throws: when Schematic cannot be reached it answers the flag's entry in ClientOptions.FlagDefaults, which is false for any flag not listed. So with the SDK client an outage denies every gated feature whatever FailurePolicy says, and the setting that decides what an outage does is the flag default:

builder.Services.AddSchematic(apiKey, o => o.FailOpenFor("advanced-reports", "exports", "ai-chat"));

FailOpenFor sets those flags' defaults to true. A genuine deny is a successful evaluation and is unaffected. FailurePolicy still matters for a custom ISchematicGateClient that does throw, and for the credit-lease middleware, whose lease calls go through the API and can fail.

Shadow mode

Between "gating is wired" and "gating is enforced", run with every denial logged and allowed:

builder.Services.AddSchematic(apiKey);
builder.Services.AddSchematicShadowMode(builder.Configuration.GetValue<bool>("Schematic:ShadowMode"));

AddSchematicShadowMode wraps the registered gate client (call it after AddSchematic, AddSchematicNoOp or your own registration). A denied check is logged at Warning as Schematic shadow mode: would deny flag '...' for company ... : reason and answered as allowed; tracking, identify and leases pass straight through. Ship a gated build with it on, read the logs, fix the plans that would have blocked a company that should have access, then turn it off. It is a rollout control, not a failure policy: a real deny is a successful evaluation, so no fail-open setting covers a plan that is missing a feature.

Caching with FusionCache

The SDK accepts an ICacheProvider for its internal caching. SchematicHQ.Community.DependencyInjection supplies one backed by FusionCache:

builder.Services.AddFusionCache();
builder.Services.AddSchematicFusionCache();          // or AddSchematicFusionCache("cache-name")
builder.Services.AddSchematic(apiKey);               // picks up the registered ICacheProvider

AddSchematic wires any DI-registered ICacheProvider into ClientOptions.CacheProvider unless one was set explicitly, so custom providers plug in the same way. Entries use the SDK's built-in default cache TTL (5 seconds) unless the SDK passes a per-entry TTL, pass AddSchematicFusionCache(defaultTtl: ...) to change it.

Note: FusionCache does not support key enumeration, so the provider's DeleteMissing is a no-op. The SDK's Datastream functionality (options.UseDatastream), where a sidecar is used to cache entitlement state, should not be used when utilizing FusionCache or a custom distributed cache as it's redundant and may cause issues.

Metering AI usage

SchematicHQ.Community.Extensions.AI plugs into the Microsoft.Extensions.AI chat pipeline:

builder.Services.AddHttpContextAccessor();
builder.Services.AddChatClient(sp => /* provider client */)
    .UseSchematicRequireFeature("ai-chat")           // deny before the model is invoked
    .UseSchematicUsageTracking();                    // then meter what allowed calls consume

Tracking reads each response's UsageDetails (streaming is included as usage is aggregated across updates and recorded even if the consumer abandons the stream).

By default, track events take ai.input-tokens and ai.output-tokens as an input and pass the the model id as a trait, fully remappable via options.MapUsage. Identity comes from the ambient HTTP request's flag-context resolver, set options.FallbackContext for background/non-HTTP calls. Denied gating throws SchematicFeatureDeniedException (with FlagKey/Reason). Check failures follow options.FailurePolicy. Tracking failures will never fail the AI call.

Anything the provider reports in UsageDetails.AdditionalCounts (such as cache reads and writes or reasoning tokens) is passed through as ai.{key} with the key normalised to kebab-case, so a Bedrock response carrying cache_read_input_tokens also emits ai.cache-read-input-tokens. Check your provider's docs before adding them to the input count, because whether they are already inside InputTokenCount differs by provider (Anthropic reports cache buckets alongside it, OpenAI counts cached tokens within it).

Implementation note: Metering token counts directly works when a feature's price is per token. To meter against credits instead, set the entitlement's priceBehavior to credit_burndown and give each event its own creditConsumptionRate. Input and output tokens can burn the same credit at different rates, which keeps the price ratio between them in Schematic rather than hard-coded in a custom MapUsage.

Reserving credits with a lease

The gate/track pair is post-paid: the balance is checked before the call and debited after it, so a long generation or several concurrent calls can all pass on the same balance and overspend. For credit-burndown entitlements, UseSchematicCreditLease replaces the pair and reserves the estimated credits first:

builder.Services.AddChatClient(sp => /* provider client */)
    .UseSchematicCreditLease("ai-chat", o =>
    {
        o.LeaseDuration = TimeSpan.FromMinutes(5);         // hold expiry if the app dies mid-call
        // o.EstimateUsage = (messages, options) => ...;     // default: ~4 chars/token in, MaxOutputTokens (or 1024) out
        // o.CreditCost = (events, entitlement) => ...;      // default: every event's quantity * entitlement.ConsumptionRate
    });

Per call it checks the flag, sizes a hold from EstimateUsage and CreditCost, and acquires a lease against the company's credit; the model runs; the actual usage is tracked against the lease (extending it first if the estimate fell short) and the unspent remainder is released. A rejected hold denies the call with reason insufficient_credits (see AllowOverdraft below). If the model call throws, the whole hold is released. When the flag's entitlement is not credit-based the middleware behaves like UseSchematicRequireFeature + UseSchematicUsageTracking.

Schematic funds what it can: an acquire for more than the balance covers returns a smaller lease rather than failing, and the middleware works from the granted amount. Lease-backed events are sent immediately rather than through the SDK's buffered path, which cannot carry a lease id. If that send fails the event falls back to the buffered path, so usage is never lost. The lease members of ISchematicGateClient have default implementations that throw NotSupportedException; a custom gate client must implement them to use this middleware.

Letting denied calls through

Sometimes a denial should be billed rather than enforced: a credit balance the customer is charged past rather than cut off at. (For observing gating before enforcing it across the whole app, use shadow mode above; it covers these middlewares too, since they gate through the same client.) Both gating middlewares take the same two options:

.UseSchematicCreditLease("ai-chat", o =>
{
    o.AllowOverdraft = true;                          // a spent balance is overage, not a refusal
    o.OnDenied = denial =>                            // every would-be denial, allowed or not
    {
        metrics.Overdraft(denial.FlagKey, denial.Response?.Entitlement?.CreditSettled);
        return ValueTask.CompletedTask;
    };
})
  • DenialBehavior = SchematicDenialBehavior.Allow invokes the model on any denial, including a missing identity. The denial is still logged and reported to OnDenied, and usage is still tracked. It does not cover a check that fails; that remains FailurePolicy. Unlike shadow mode it applies to one pipeline and passes the entitlement to OnDenied, so it fits a call that should proceed for billing reasons rather than a rollout.
  • AllowOverdraft (lease middleware only) allows just the denials that are about credit: a check denied for a spent balance, or a hold Schematic will not fund. The model runs without a hold and its usage goes through the buffered path, which debits the grant past zero, the overage the customer is then billed or topped up for. A denial that is not about credit, such as no entitlement at all, still denies.
  • OnDenied is invoked for every would-be denial before the middleware throws or lets it through; SchematicAiDenial.Allowed says which, and Response carries the entitlement with its balance. An exception thrown from the callback is logged and changes nothing.

Answering a denied call

A denial inside a request handler surfaces as SchematicFeatureDeniedException, which would otherwise become a 500. One middleware, placed before routing, turns it into the gate's response (OnDenied, or 403 ProblemDetails with featureId and accessDeniedReason):

app.UseSchematicFeatureDeniedResponses();

It is a middleware rather than an IExceptionHandler because UseExceptionHandler logs every exception at Error before consulting its handlers, so a denial answered with a 403 would still be logged as an error. SchematicDenialResponse.WriteAsync is public for apps that want to write their own handler.

Gating and tracking Quartz jobs

SchematicHQ.Community.Extensions.Quartz applies the same gate/track model to scheduled jobs:

builder.Services.AddSchematicQuartz();                 // options, resolver, listeners
builder.Services.AddQuartz(q =>
{
    q.AddSchematic();                                  // wires the listeners and trait report schedules into the scheduler
});

Decorate job classes:

[RequireFeature("nightly-sync")]                       // execution vetoed when not entitled
[TrackFeature("nightly-sync-runs")]                    // tracked after each successful run
public sealed class NightlySyncJob : IJob { ... }

The company/user identity comes from schematic.company.* / schematic.user.* entries in the merged job data map, declare them with .UsingSchematicCompany("id", tenantId) on the job or trigger builder (or the configurator inside AddQuartz), or register a custom ISchematicJobContextResolver.

Check failures follow AddSchematicQuartz(o => o.FailurePolicy = ...), and tracking failures never fail the job.

Reporting traits on a schedule

Traits hold stateful facts (seat counts, storage used) that entitlements compare against, and are usually computed from your own database. A report is a catalog (which tenants?) plus a source (what are this tenant's traits?):

public sealed class TenantCatalog(CatalogDbContext db) : ISchematicTenantCatalog
{
    public IAsyncEnumerable<string> GetTenantIdsAsync(TraitReportContext context, CancellationToken ct)
        => db.Tenants.Select(t => t.Id).AsAsyncEnumerable();
}

public sealed class SeatSource(ITenantDbContextFactory dbFactory) : ISchematicTraitReportSource
{
    public async Task<CompanyTraitReport?> GetReportAsync(string tenantId, TraitReportContext context, CancellationToken ct)
    {
        await using var db = dbFactory.CreateForTenant(tenantId);
        return new(Keys: new() { ["id"] = tenantId },
                   Traits: new() { ["seats"] = await db.Users.CountAsync(ct) });
    }
}

builder.Services.AddSchematicTraitReport<TenantCatalog, SeatSource>("seats", o => o.Cron = "0 0 3 * * ?");

The source receives each tenant id and handles tenancy itself, return null to skip a tenant. Tenants are processed with bounded parallelism, so you're safe to acquire per-tenant resources inside the call. With AddSchematicQuartz and q.AddSchematic(), every report that sets a cron runs on that schedule (missed runs fire once on startup; trait upserts are last-write-wins, so re-runs are safe). One failing tenant is logged and retried on the next run without sinking the rest. Reports without a cron — or apps not using Quartz — run on demand via ISchematicTraitReportRunner.RunReportAsync("seats").

Set o.ScheduleEnabled = false to keep a report registered and on-demand runnable without anything firing it on a schedule:

builder.Services.AddSchematicTraitReport<TenantCatalog, SeatSource>("seats", o =>
{
    o.Cron = "0 0 3 * * ?";
    o.ScheduleEnabled = !builder.Environment.IsEnvironment("IntegrationTest");
});

An integration test host that boots your real application would otherwise fan out over every tenant and write to Schematic partway through a suite. It also covers nominating one instance to own reporting when several run the same configuration.

Testing your app

The filters, AI middlewares and Quartz listeners call Schematic through the ISchematicGateClient seam. SchematicHQ.Community.Testing supplies a fake for it:

var fake = new FakeSchematicGateClient();

await using var host = await AlbaHost.For<Program>(webHost =>
    webHost.ConfigureServices(services => services.AddSchematicFake(fake)));

fake.Deny("advanced-reports");                       // every identity
fake.DenyForUser("user_42", "exports");              // only checks made as that user
fake.ThrowOnCheck = new HttpRequestException();      // Schematic unreachable; every Throw* switch takes an exception
fake.RespondToCheck(flag => new CheckFlagWithEntitlementResponse { ... });   // script the full response

fake.CheckCalls.Single().FlagKey.ShouldBe("advanced-reports");
fake.TrackCalls.ShouldBeEmpty();
fake.Reset();                                        // between tests

Every check is allowed until a test says otherwise. Checks, tracks, identifies and lease calls are recorded; credit leases are granted in full unless RejectLease or ThrowOnAcquireLease is set. AddSchematicFake replaces whatever gate client the application registered and also exposes the fake itself from DI. The fake is safe to share across the parallel tests of one host.

For environments with no API key at all — local development, CI, preview deployments — AddSchematicNoOp() registers a client that talks to nothing. AddSchematic rejects a missing key, and everything that takes ISchematicGateClient fails at resolve time without a stand-in. Branch on the key so that wiring stays unconditional and those code paths still execute off a key:

var apiKey = builder.Configuration["Schematic:ApiKey"];

if (!string.IsNullOrWhiteSpace(apiKey))
    builder.Services.AddSchematic(apiKey);
else
    builder.Services.AddSchematicNoOp();       // Track/Identify discarded, checks allow

builder.Services.AddSchematicAspNetCore();     // unchanged either way

Track and Identify are discarded, and entitlement checks resolve true so gated features stay reachable — pass AddSchematicNoOp(allowAll: false) to assert denial paths instead. Because it opens every gate, branch on whether a key is configured, not on whether one failed to load: reaching this in production would entitle everybody. Call it instead of AddSchematic, never as well as — whichever registers first wins.

License

Apache-2.0

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
0.4.0 29 9/22/2026
0.3.0 51 9/21/2026
0.2.0 123 8/19/2026
0.1.0 111 8/18/2026