M6d.Cortex 0.1.0

There is a newer version of this package available.
See the version list below for details.
dotnet add package M6d.Cortex --version 0.1.0
                    
NuGet\Install-Package M6d.Cortex -Version 0.1.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="M6d.Cortex" Version="0.1.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="M6d.Cortex" Version="0.1.0" />
                    
Directory.Packages.props
<PackageReference Include="M6d.Cortex" />
                    
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 M6d.Cortex --version 0.1.0
                    
#r "nuget: M6d.Cortex, 0.1.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 M6d.Cortex@0.1.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=M6d.Cortex&version=0.1.0
                    
Install as a Cake Addin
#tool nuget:?package=M6d.Cortex&version=0.1.0
                    
Install as a Cake Tool

M6d.Cortex

Turns an ASP.NET Core application into a Cortex dataset host. You name the tables the agent may read and the rows each user may see; the package derives the columns and relations from your EF Core model, serves the manifest, and runs every query the Cortex Control Center forwards, under the user's identity, inside the cap you set.

GET  /api/datasets/          -> { version, datasets: [{ name, description, columns, relations, maxRows }] }
POST /api/datasets/{name}    <- { query, context: { threadId } }
                             -> { rows, rowCount, truncated } | { error: { kind, detail } }

The wire protocol is defined once, in internal/contracts/src/runtime/datasets.ts; this package implements it the same way the TypeScript reference host in @m6d/cortex-server/datasets does, and the conformance fixtures in datasets.fixtures.json pin every shape's rows for both.

What a query can ask

A query names a dataset, joins related datasets by relation name (any depth, through for many-to-many) and reaches their columns as relation.column. Everything else is built from two shapes:

  • An operand is a column reference, a number, a text or boolean literal { value }, arithmetic { op, left, right } (division is exact), a date truncated to a period { fn: "dateTrunc", unit, of } (answered as the ISO date of the period's first day, weeks starting Monday) or one of its parts { fn: "datePart", unit, of } (an integer; dayOfWeek is 1 for Monday to 7 for Sunday), a scalar function { fn, args } (lower, upper, trim, length, abs, round, coalesce), or { case: [{ when, then }], else } whose branches share one type.
  • A filter is a typed condition over an operand (the right side a literal or { col: operand }), or { any }, { all }, { not } over filters. not keeps SQL's null semantics: a row whose comparison is unknown is left out either way.

With those, a query selects columns or named values { expr, as }, filters rows, groupBys columns or named values, aggregates (count, countDistinct, sum, avg, min, max over an operand, each with an optional filter over its own rows), filters the grouped rows with having, answers a window on every row (the same aggregates but countDistinct over the answered rows sharing the row's partitionBy values, all rows when empty: SQL's fn(col) OVER (PARTITION BY ...)), computes named values per row from the row's own names and the window aliases, filters the finished rows with qualify (SQL's QUALIFY, where a window or computed alias may appear; having refuses them), keeps the first n rows of each partition with rank (SQL's RANK() OVER (PARTITION BY ... ORDER BY ...) <= n), then sorts, offsets (only with a sort) and limits. Row keys are the column reference (department.name), the group key's or select value's alias, the aggregate alias, the window alias or the computed alias.

This package translates each of these into query expressions your EF Core provider runs in the database: dates as year, month and day arithmetic over the column, functions as the string and Math methods providers know, rank as a correlated count of the rows that sort ahead within the partition (so rows tied on every rank sort key share a rank and all stay, as RANK keeps them; name a unique column last to keep exactly n), a window as the same correlated subquery with the window's aggregate over the answered rows of the partition (an avg is taken over doubles, the number the wire carries), and compute as the aggregate and window expressions substituted into the arithmetic. qualify drops rows before rank numbers them.

Install

dotnet add package M6d.Cortex

Targets net10.0. Depends on Microsoft.EntityFrameworkCore and the Microsoft.AspNetCore.App shared framework; bring your own database provider.

Register and expose

using M6d.Cortex.Datasets;

builder.Services.AddCortexDatasets(datasets =>
{
    datasets.Expose("customers", (AppDbContext db) => db.Customers)
        .Describe("Everyone who has ordered.");

    datasets.Expose("orders", (AppDbContext db, ICurrentUser user) =>
            db.Orders.Where(order => order.TenantId == user.TenantId))
        .Describe("One row per order: who bought what, for how much, and when.")
        .Describe(order => order.Total, "Order total after quantity")
        .Exclude(order => order.InternalNotes)
        .WithMaxRows(1000);
});

var app = builder.Build();
app.MapCortexDatasets("/api/datasets");

Expose takes a delegate whose parameters are resolved from the request's service provider on every call, so a scoped DbContext and your own current-user service both work. The DbContext parameter is also where the EF Core model is read from when the routes are mapped, so the delegate itself never runs outside a request (the IServiceProvider overload has no such parameter and runs once at startup instead). The row type is inferred from the delegate's return; the manifest lists that entity's scalar properties as columns (string, number, boolean, date; binary, enum and nested-object properties are left out) and its navigations as relations, camelCased (CustomerId becomes customerId, Customer becomes customer).

The Where is your access rule. It runs before anything the query adds, so a caller can only ever narrow the rows you scoped, never widen them; a sort in the query replaces any order the scope carried. Throw DatasetForbiddenException from the delegate to refuse a read outright.

Expose each entity as a dataset once. Two datasets that expose the same entity without a projection make every relation to it ambiguous, so MapCortexDatasets refuses to start; project one of them instead.

Relations

A reference navigation becomes relation: { dataset, on: [local, remote] } over its foreign key; a collection navigation the same, seen from the principal side; a skip navigation (many-to-many) gets a through naming the join entity. A relation appears only when the target entity is exposed as a dataset and both key columns are exposed. Queries reach related columns as customer.name; a path a reference names is joined whether or not join lists it. Any depth works (pet.tags.name), and collection joins flatten the rows the way a LEFT JOIN would.

Project

Replace the entity with a DTO when the table has more than the agent should see, or when you want computed columns:

datasets.Expose("orders", (AppDbContext db, ICurrentUser user) => db.Orders.Where(...))
    .Project(order => new OrderRow
    {
        Id = order.Id,
        CustomerId = order.CustomerId,
        Customer = order.Customer,
        Amount = order.Total * order.Quantity,
    })
    .Describe(row => row.Amount, "Total times quantity");

The DTO's scalar properties are the columns. A property assigned straight from a navigation (Customer = order.Customer) stays a relation, as long as the foreign key it joins on is projected too (CustomerId here). Write the projection as an object initializer (or an anonymous type); a constructor call hides which member each value feeds and is refused. Call Project before Exclude and column Describe, which apply to the projected members.

Exclude, Describe, WithMaxRows

Exclude(o => o.Secret) drops a column or a relation from the manifest, so no query can name it. Describe("...") sets the dataset description the model reads in the catalog; Describe(o => o.Total, "...") describes one column. WithMaxRows(n) caps a query's answer (default 500, at most 5000); the host clamps whatever limit the caller sent and answers truncated: true when more rows matched.

Definitions are checked against the contract when the routes are mapped: names match ^[a-zA-Z][a-zA-Z0-9_]*$ (at most 64 characters), a description is at most 500 characters, a column description 200, a dataset has 1 to 200 columns. A definition outside those limits fails startup with the reason.

Who is asking

Cortex forwards the end user's token in a header, X-End-User-Token by default. Change the header or strip a prefix through the options:

datasets.Options.UserTokenHeader = "Authorization";
datasets.Options.UserTokenPrefix = "Bearer ";
datasets.Options.QueryTimeout = TimeSpan.FromSeconds(10);

An ICortexUserResolver turns that token into the ClaimsPrincipal the request runs as; the package sets HttpContext.User to it before your expose delegate runs, so a current-user service over IHttpContextAccessor sees the right caller. There is no silent default: MapCortexDatasets fails at startup until a resolver is registered. Behind a gateway that already verified the token you can opt into the unverified one, which reads the JWT subject without checking the signature into the sub and name-identifier claims:

builder.Services.AddCortexDatasets(datasets =>
{
    datasets.UseUnverifiedJwtSubject();
    // ...
});

Anywhere else, register your own:

public sealed class VerifyingResolver(JwtValidator jwt) : ICortexUserResolver
{
    public async ValueTask<ClaimsPrincipal> ResolveAsync(
        string? token, HttpContext httpContext, CancellationToken cancellationToken)
    {
        if (token is null) throw new DatasetForbiddenException("An end-user token is required");
        return await jwt.ValidateAsync(token, cancellationToken)
            ?? throw new DatasetForbiddenException("The end-user token is not valid");
    }
}

builder.Services.AddSingleton<ICortexUserResolver, VerifyingResolver>();

A null token means no header came (a headless run under a service credential); return an unauthenticated principal or refuse, as your rules say.

Errors

Anything the query gets wrong against the manifest answers invalid_query (400) with the same messages the Cortex server produces; so does a body that is not JSON, or a filter value that does not fit its column (1.5 on an integer column, a date that does not parse). A date value without an offset is read as UTC. unsupported (400) means the query is valid but this host cannot translate it. A DatasetForbiddenException from a resolver or an expose delegate answers forbidden (403). A query that outlives QueryTimeout answers timeout (504). Everything else is logged and answered as internal (500) with a fixed detail; the exception message never leaves the process.

Registering the host in the Cortex Control Center

In the Control Center, add a dataset host with the URL you mapped (https://your-app.example/api/datasets) and the credential it should call you with (a service API key header, for instance). The Control Center reads the manifest, lets you pick which datasets an agent exposes and which are pinned into every turn, and from then on runs the agent's queries against POST {url}/{name} with the user's token in the identity header. Protect the mapped group the way you protect any service endpoint:

app.MapCortexDatasets("/api/datasets").RequireAuthorization("cortex-service");

Development

The package is driven through bun like the rest of the repo. scripts/dotnet.ts finds the SDK, refuses one other than the version global.json pins (10.0.300), and runs dotnet with telemetry and the banner off.

bun run --filter @m6d/cortex-dotnet build    # dotnet build, Release
bun run --filter @m6d/cortex-dotnet test     # dotnet test over SQLite in-memory
bun run --filter @m6d/cortex-dotnet lint     # csharpier check
bun run --filter @m6d/cortex-dotnet format   # csharpier format
bun run --filter @m6d/cortex-dotnet check    # build + lint
bun run --filter @m6d/cortex-dotnet pack     # nupkg into artifacts/

Every warning is an error. Directory.Build.props turns on nullable reference types, AnalysisLevel latest-all, EnforceCodeStyleInBuild, and XML docs; .editorconfig raises StyleCop, Sonar, Roslynator and Meziantou rules to error along with the naming and language style rules, and lists the rules that are off with a one-line reason each (formatting belongs to CSharpier, private members need no XML docs, a few rules fight records and primary constructors). Formatting is CSharpier's, through the tool manifest in .config/dotnet-tools.json; the root lint-staged config formats staged .cs files with it.

The tests run the host in-process over SQLite in-memory and include the shared conformance fixtures from internal/contracts/src/runtime/datasets.fixtures.json, which every dataset host implementation answers identically.

Product Compatible and additional computed target framework versions.
.NET 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.14.0 0 11/3/2026
0.13.0 42 11/2/2026
0.12.0 56 10/29/2026
0.11.0 78 10/28/2026
0.10.0 86 10/28/2026
0.9.0 81 10/27/2026
0.8.0 95 10/25/2026
0.7.0 94 10/25/2026
0.6.0 88 10/22/2026
0.5.0 88 10/21/2026
0.4.0 97 10/14/2026
0.3.0 96 10/12/2026
0.2.1 93 10/11/2026
0.2.0 102 10/11/2026
0.1.0 106 10/3/2026