A Scry solution has three projects:
| Project | References | Role |
|---|---|---|
| Model | Scry.Annotations, Microsoft.EntityFrameworkCore |
The EF Core DbContext and entities, annotated with the allow-list attributes. |
| Server | Scry.Server, the model project |
Hosts the DbContext and maps the query endpoint. |
| Client | Scry.Client, the model project by path only |
Writes LINQ against the generated query models. |
The client does not reference the model. It points at the model's built DLL with an MSBuild property, and the source generator reads that file as metadata. Nothing from the model assembly is loaded, referenced, or executed on the client, and nothing but the allow-listed surface reaches generated code.
[Queryable]
public class Employee
{
public int Id { get; set; }
public string Name { get; set; } = "";
public Status Status { get; set; }
public bool Active { get; set; }
public DateOnly Created { get; set; }
public int? ManagerId { get; set; }
public Employee? Manager { get; set; }
public int DepartmentId { get; set; }
public Department? Department { get; set; }
// A claim check rather than a value: no query reads it, and what a client gets back is a handle
// carrying this row's key. A photo is the case the attribute exists for — bytes nothing wants on
// every row of every query, fetched by the one thing that actually wants to draw them. The check
// that authorizes the fetch is registered by the server; this project references the annotations
// alone, so [AttachmentWith] has no policy type to name here.
[Attachment(ContentType = "image/svg+xml")]
public byte[]? Photo { get; set; }
// Never exposed to clients.
[QueryIgnore]
public decimal Salary { get; set; }
// The other half of that pair: queryable, but never in a URL and never in a cache. [QueryIgnore]
// hides a member outright; [Sensitive] keeps it askable while refusing the two ways its value
// escapes — a query comparing it against a constant travels as a body rather than a URL, where the
// constant would land in every access log on the way, and a response projecting it is sent
// no-store, where a cacheable one would be written to the caller's disk.
[Sensitive]
public string Password { get; set; } = "";
}[Queryable] opts the type in; nothing is exposed without it. Every public readable property is then exposed unless it carries [QueryIgnore]. See Annotations for views, POCOs, and the exact member rules.
The DbContext is an ordinary EF Core context — Scry needs no changes to it:
public DbSet<Department> Departments => Set<Department>();
public DbSet<Employee> Employees => Set<Employee>();
public DbSet<Order> Orders => Set<Order>();
public DbSet<EmployeeSummary> EmployeeSummaries => Set<EmployeeSummary>();
public DbSet<Asset> Assets => Set<Asset>();
protected override void OnModelCreating(ModelBuilder builder)
{
builder.Entity<EmployeeSummary>()
.HasNoKey()
.ToView("EmployeeSummary");
// Table-per-hierarchy: the derived types share the base table and are told apart by a
// discriminator, which is what OfType narrows on.
builder.Entity<Vehicle>();
builder.Entity<Building>();
}The model project references Scry.Annotations alongside EF Core:
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" />
<ProjectReference Include="..\..\src\Scry.Annotations\Scry.Annotations.csproj" />
</ItemGroup>Register the DbContext as usual, then register Scry against it:
services
.AddScry<SampleContext>(_ =>
{
// Holiday is a [QueryablePoco]: it has no table, so the server supplies its rows. Every
// [QueryablePoco] type must be registered here or AddScry throws at startup.
_.AddPocoSource(_ => Holiday.Seed());
// Department.Handbook and Employee.Photo are [Attachment]s, and one exposed without a
// check is a startup failure. Registered here rather than by [AttachmentWith] because
// the model project references the annotations alone and has no server type to name.
_.AddAttachmentPolicy<Department, HandbookPolicy>();
_.AddAttachmentPolicy<Employee, PhotoPolicy>();
_.MaxPageSize = 200;
// A row policy whose decision is too slow to run per row in SQL, so it runs in C# and
// the server remembers what it answered. Revision is what tells it a row has changed
// and needs deciding again — see /docs/policies.md and the /permissions page.
_.AddCachedPolicy<Order, long, RegionAccessPolicy>(_ => _.Revision);
// Repeat a query while nothing has been written and the answer is a 304 rather than a
// re-execution. Optional, and off until a freshness source says how to tell — see
// /docs/caching.md.
_.UseDeltaFreshness<SampleContext>();
// What a cached response belongs to. This server has sources whose answers depend on
// who asked — the row policy above, and Department.Handbook's attachment check — and
// MapScry refuses to start without this. The sample has no sign-in, so the caller
// half is a constant; a real app returns its tenant or its principal, and a client
// signing in as someone else is then never handed the previous one's rows.
//
// The grants version is the other half, and is the part worth copying. A response
// varies by what the caller is allowed to see, and QueryFreshness only watches the
// database — so a grant changing outside it would move nothing, and a cache holding
// the old rows would go on answering with rows the caller has since lost.
_.CacheScope = _ => $"sample-{_.RequestServices.GetRequiredService<RegionGrants>().Version}";
// Live queries: the /live pages. Off until a server says how many it will hold open,
// which is also what maps the route — see /docs/live-queries.md.
_.MaxSubscriptions = 100;
// The interceptor above reports this server's own saves, at once and by entity. This
// watches the database's change marker for everything it cannot see: a bulk update,
// another node, a script run by hand.
_.UseDeltaChanges<SampleContext>();
// Commands: the /commands page and the /live pages' Reprice. Off until a server says
// how many it will have in flight, which is also what maps the routes — see
// /docs/commands.md.
_.UseSampleCommands();
// An AI agent's way in: the schema, queries and commands over MCP, at /mcp. Off
// until a server says what an agent may do — see /docs/mcp.md.
_.Mcp = ScryMcpAccess.ReadWrite;
});AddScry<TContext> scans typeof(TContext).Assembly once at startup, builds the allow-list schema, and registers it as a singleton along with the ScryProcessor.
AddPocoSource is what supplies the rows for a [QueryablePoco] type, which has no table for the server to read — see POCO sources for the fixed and per-request forms.
Then map the endpoint:
app.MapScry("/api/query");That is a single HTTP endpoint which accepts a serialized query and returns the projected rows. It answers POST, where the query is the body, and GET, where the query rides in the URL so the response can be cached and revalidated. See Server for all options, and Row policies for row-level filtering.
Point at the model DLL:
<!-- The server model, pointed at by path. NOT referenced. -->
<ScryModelDll>$(MSBuildThisFileDirectory)..\Sample.Model\bin\$(Configuration)\net10.0\Sample.Model.dll</ScryModelDll>and add a build-ordering-only project reference:
<ItemGroup>
<PackageReference Include="Scry.Client" />
<!-- Build ordering only — NOT a real reference. -->
<ProjectReference Include="..\Sample.Model\Sample.Model.csproj" ReferenceOutputAssembly="false" />
</ItemGroup>The Scry.Client package brings the generator and the MSBuild targets that feed it the path, so those two lines are the whole setup. Source generator covers what a project needs when it references Scry.SourceGenerator directly instead of via the package, and The model as a package what changes when the model ships as a NuGet package rather than a project.
Register the client and the generated entry point:
var services = builder.Services;
services.AddHttpClient(
"scry",
_ => _.BaseAddress = new(builder.HostEnvironment.BaseAddress));
services.AddScryClient(
"/api/query",
_ => _.GetRequiredService<IHttpClientFactory>().CreateClient("scry"));
services.AddScoped<ScryQuery>();AddScryClient registers a ScryClient that sends to the given endpoint using the HttpClient the delegate resolves — here a named one, so Scry's base address, and any handler pipeline it grows, stay separate from every other call the app makes. ScryQuery is generated into the Scry.Generated namespace.
The factory is reached through that delegate rather than being a dependency of Scry.Client, so an application that does not otherwise want Microsoft.Extensions.Http does not acquire it by referencing Scry.
A shorter overload takes whichever HttpClient the container holds:
services.AddScoped(
_ => new HttpClient
{
BaseAddress = new("https://localhost")
});
services.AddScryClient("/api/query");
services.AddScoped<ScryQuery>();That is a fair shortcut in WebAssembly, where the browser backs HttpClient, there is exactly one, and it already points at the app's own origin — so there is nothing for a name to disambiguate, and it saves the app a Microsoft.Extensions.Http reference it would otherwise carry into the browser. Prefer naming the client anywhere else: a bare HttpClient registration is discouraged outside WASM to begin with, and an ambient one may well belong to another API, which Scry would then quietly post to. A desktop or console client also has a socket pool and a DNS lifetime for the factory to manage, which the browser does not — see Client hosts.
Either way the client is registered scoped, not transient. It records the schema stamp each response advertises and raises SchemaStaleDetected at most once, so a fresh instance per injection would reset that and never report drift. That is also why a typed client (AddHttpClient<ScryClient>) is the wrong shape here: the factory registers those transient.
Declare whatever shape the UI wants:
record EmployeeRow(string Name, Status Status, string? Manager, string Department);
record RegionSummary(string Region, decimal Total, int Count);then write ordinary LINQ:
employees = await Query
.Employee
.Where(_ => _.Active)
.OrderBy(_ => _.Name)
.Select(_ => new EmployeeRow(_.Name, _.Status, _.Manager!.Name, _.Department!.Name))
.ToListAsync();That query is captured — never executed client-side — serialized to the wire AST, sent, validated against the allow-list on the server, rebound to the real Employee type, run through EF Core, and returned as exactly the four projected columns.
Adding a new query is more LINQ. No new endpoint, no new contract, no server change.
Salaryis[QueryIgnore], so it is absent fromEmployeeQueryModel— there is no way to write LINQ that references it. A hand-crafted request namingSalaryis rejected with400by the server's independent validation pass.- The
Statusenum is re-emitted client-side, so no model reference is needed to compare againstStatus.FullTime. - Collection navigations (
Department.Employees) are not exposed at all, so a client cannot fan a query out across a collection.
Next: Writing queries for the full supported surface, or Security model for what is enforced and where.