M6d.Cortex
0.1.0
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
<PackageReference Include="M6d.Cortex" Version="0.1.0" />
<PackageVersion Include="M6d.Cortex" Version="0.1.0" />
<PackageReference Include="M6d.Cortex" />
paket add M6d.Cortex --version 0.1.0
#r "nuget: M6d.Cortex, 0.1.0"
#:package M6d.Cortex@0.1.0
#addin nuget:?package=M6d.Cortex&version=0.1.0
#tool nuget:?package=M6d.Cortex&version=0.1.0
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;dayOfWeekis 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.notkeeps 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 | Versions 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. |
-
net10.0
- Microsoft.EntityFrameworkCore.Relational (>= 10.0.12)
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 |