Type-safe, serializable LINQ from a client to a server-side EF Core model.
When a UI evolves quickly, server-side querying usually forces a choice between hand-coding a bespoke endpoint and contract per use case, or adopting GraphQL/OData and shaping queries with a separate query language. Scry removes that trade-off while keeping everything in C# and strongly typed end to end:
- The EF Core model lives server-side. The client never references it — it is pointed at by path.
- A source generator in the client reads the model assembly directly by path (
System.Reflection.Metadata), applies an allow-list, and generates strongly-typed client query DTOs plus a queryable entry point. - The UI writes ordinary LINQ against the generated types.
- The LINQ is captured and serialized to a restricted query AST.
- The server deserializes, re-validates against the allow-list at runtime, rebinds to the real EF types, executes, and returns the projected rows.
Add or extend a query by writing LINQ in the client — no new endpoint, no new contract — while the server stays in full control of which types, properties, shapes, and rows can ever be returned.
Scry is designed for a .NET client that runs outside the server's process — a Blazor WebAssembly front end, a WPF or Windows Forms desktop app, a console tool, or another service — talking to its own back end. The client has no EF dependency, so it stays small under a trimmed WebAssembly publish and light in an installed desktop app, while remaining strongly typed against the server's EF Core model. Client hosts covers what each host needs.
It also assumes the client and the back end are built by the same team and deployed together. A generated client is bound to the model surface it was generated against, and the two are expected to move in lockstep. Scry is deliberately not a general-purpose web API: it is not intended as a stable public contract for multiple external consumers, third-party apps, or clients on release cycles the team does not control. See docs/schema-versioning.md for how drift between the two is detected and mitigated.
"Same team" is about coupling, not trust. The client is still treated as hostile — the generated code, the LINQ, and the wire request are all attacker-controlled — and every guarantee is re-enforced server-side at runtime. See docs/security.md.
Scry sits in a narrow slot beside hand-written endpoints, schema-language query APIs, URL query conventions, expression-tree serializers, and database-generated APIs. Comparisons places each by type and by name, and lists when Scry is the wrong choice.
The build-time and runtime flows are deliberately independent. Nothing is referenced across the client/server boundary: the only things that cross it are the model dll by path (build time) and the serialized wire AST (run time).
The source generator reads the EF model assembly by path and emits strongly-typed client query types from the allow-listed surface only. The assembly is never referenced, loaded, or executed.
flowchart TB
subgraph model["Server model"]
EF["EF model<br/>+ Scry.Annotations<br/>([Queryable], [QueryIgnore], …)"]
DLL["Model dll"]
EF --> DLL
end
subgraph client["Client (no EF dependency)"]
GEN["Source generator<br/>reads dll via<br/>System.Reflection.Metadata"]
GENTYPES["Generated query types<br/>(Scry.Generated)"]
GEN --> GENTYPES
end
DLL -. "by path, never referenced" .-> GEN
The client's LINQ is captured (never executed client-side) and serialized to a restricted AST. The server re-validates that AST against the allow-list — to completion, before anything is respond — then rebinds to the real EF types, executes, and returns only the projected rows. A byte[] member marked [BinaryTransfer] skips base64 and travels as a raw multipart part beside the JSON (binary transfer); one marked [Attachment] is not carried at all, and is fetched on demand by row key through a check of its own (attachments).
flowchart TB
subgraph client["Client"]
LINQ["UI writes linq<br/>against generated types"]
CAPTURE["QueryProvider<br/>captures expression tree<br/>(never executed here)"]
TRANS["QueryTranslator<br/>→ restricted query AST"]
LINQ --> CAPTURE --> TRANS
end
subgraph wire["Scry.Wire"]
REQ["QueryRequest AST<br/>(closed operator + node set)"]
end
subgraph server["Server"]
SCHEMA["Schema.Build<br/>allow-list from the real model"]
VALID["QueryValidator<br/>authoritative gate<br/>(runs to completion first)"]
BUILD["ExpressionBuilder<br/>rebind to real EF types"]
EXEC["QueryExecutor + ProjectionPlan<br/>execute + shape rows"]
DB[("EF → DB")]
RESP["QueryResponse"]
SCHEMA -. "allow-list" .-> VALID
VALID --> BUILD --> EXEC --> DB
DB -- "projected rows" --> RESP
end
TRANS -- "serialize + send" --> REQ
REQ -- "deserialize" --> VALID
RESP -- "rows" --> LINQ
See docs/security.md for the full threat model.
Queries never write. Writes are commands: a class in the model marked [Command], generated into the client beside the query models and sent through Query.Commands. The server binds the payload into its own class, decides it with the command's policy — row by row, for a command that targets a row — and hands it to an in-process handler or a message bus. A command decided within a second answers at once; one that takes longer answers Pending and streams its outcome on the same response. What it wrote reaches every screen through the live queries that read it.
| Package | Purpose |
|---|---|
| Scry.Annotations | Allow-list attributes applied to the server model. |
| Scry.Wire | The serializable query AST shared by client and server. |
| Scry.Client | Client-side IQueryable provider (no EF dependency). Ships the source generator. |
| Scry.Server | Server-side validation + execution against EF Core. |
| Scry.Server.Explorer | Opt-in, GraphiQL-style query explorer. |
| Scry.Server.Delta | Opt-in 304 Not Modified, and a change probe for live queries, backed by Delta. |
| Scry.Server.SignalR | Opt-in: the query surface over a SignalR hub, so many live queries share one connection. |
| Scry.Client.SignalR | Opt-in: a ScryClient over a SignalR hub connection. |
| Scry.Server.Mcp | Opt-in: the query surface, and optionally commands, served to AI agents over MCP. |
| Scry.Server.Redis | Opt-in: carries live-query change notifications between server nodes over Redis pub/sub. |
| Scry.Server.MessagePipe | Opt-in: the same over MessagePipe's distributed pub/sub. |
| Scry.Server.NServiceBus | Opt-in: the same over NServiceBus, including what a worker endpoint's handlers save — and commands carried to a worker and answered when it replies. |
| Scry.Server.MassTransit | Opt-in: commands carried over MassTransit. |
| Scry.Server.Rebus | Opt-in: commands carried over Rebus. |
| Scry.Server.Wolverine | Opt-in: commands carried over Wolverine. |
Scry.SourceGenerator is packed inside Scry.Client rather than published separately.
Every package puts its public types in the single Scry namespace, so one using Scry; covers all of them. The generated query models are the exception — they land in Scry.Generated.
Annotate the server model:
[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; } = "";
}Register and map on the server:
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;
});AddPocoSource supplies the rows for a [QueryablePoco] type — see POCO sources.
app.MapScry("/api/query");Point the client at the model by path — no reference:
<!-- The server model, pointed at by path. NOT referenced. -->
<ScryModelDll>$(MSBuildThisFileDirectory)..\Sample.Model\bin\$(Configuration)\net10.0\Sample.Model.dll</ScryModelDll>Then write LINQ:
employees = await Query
.Employee
.Where(_ => _.Active)
.OrderBy(_ => _.Name)
.Select(_ => new EmployeeRow(_.Name, _.Status, _.Manager!.Name, _.Department!.Name))
.ToListAsync();Then send a command, and read what came of it:
static async Task<int> Reprice(ScryQuery query, int id)
{
var outcome = await query.Commands.RepriceOrder(new() {Id = id});
if (outcome.Status == ScryCommandStatus.Pending)
{
Console.WriteLine($"Order {id} is being repriced…");
outcome = await outcome.Completion;
}
switch (outcome.Status)
{
case ScryCommandStatus.Completed:
Console.WriteLine($"Order {id} repriced.");
return 0;
case ScryCommandStatus.Failed:
await Console.Error.WriteLineAsync($"Order {id} was not repriced: {outcome.Error}");
return 1;
default:
await Console.Error.WriteLineAsync($"Whether order {id} was repriced is unknown: {outcome.Error}");
return 1;
}
}Any query can be kept answered. Live() in place of ToListAsync() — or LiveCount(), LiveAny(), LiveFirstOrDefault() — is answered now, and again whenever its answer changes:
protected override void Start()
{
// The LINQ is what it would be for ToListAsync. Live() in its place means the answer keeps
// arriving: now, and again whenever the rows it reads change.
rows = Query
.Order
.OrderBy(_ => _.Id)
.Select(_ => new OrderRow(_.Id, _.Region, _.Amount))
.Live()
.Subscribe(
answer =>
{
orders = answer;
InvokeAsync(StateHasChanged);
},
Failed);
// Any terminal can be live. This one is a second subscription of its own, and the server
// sends it a number rather than the rows.
counting = Query
.Order
.LiveCount()
.Subscribe(
answer =>
{
count = answer;
InvokeAsync(StateHasChanged);
},
Failed);
}
// A subscription outlives nothing: leaving the page ends it, and the server is told.
protected override async ValueTask Stop()
{
if (rows is not null)
{
await rows.DisposeAsync();
}
if (counting is not null)
{
await counting.DisposeAsync();
}
}It is consumed as a stream (await foreach), with a callback, or as a plain IObservable for Rx, with no reactive library referenced by Scry. Every answer is the query run again through the allow-list and the row policies, sent only where it differs from the one before, so a change the caller may not see produces no answer. It is off until a server sets MaxSubscriptions.
What tells one to run again is an EF SaveChanges interceptor, the host, the database's own change marker, and a poll beneath them all. Opt-in packages carry changes between server nodes over Redis, MessagePipe or NServiceBus, and serve the whole query surface over a SignalR hub. See Live queries.
A write is a class the model marks [Command]. The client sends it through the generated Query.Commands, and the server binds it into its own class, decides it with the command's policy and hands it to a handler:
/// <summary>Deletes one employee — an inactive one, by the sample's policy.</summary>
[Command(typeof(Employee))]
public class DeleteEmployee
{
public int Id { get; set; }
}
/// <summary>Renames one employee. A name containing "slow" takes a while, to show a command going pending.</summary>
[Command(typeof(Employee))]
public class RenameEmployee
{
public int Id { get; set; }
[StringLength(100, MinimumLength = 1)]
public string Name { get; set; } = "";
}
/// <summary>Deactivates or reactivates one employee: what makes a row deletable, and deletable again.</summary>
[Command(typeof(Employee))]
public class SetEmployeeActive
{
public int Id { get; set; }
public bool Active { get; set; }
}
/// <summary>Hires an employee, answering with the new row's id.</summary>
[Command(Result = typeof(EmployeeCreated))]
public class CreateEmployee
{
[StringLength(100, MinimumLength = 1)]
public string Name { get; set; } = "";
public int DepartmentId { get; set; }
public Status Status { get; set; }
}
public class EmployeeCreated
{
public int Id { get; set; }
}A targeted command adds a Can{Command} member to its target's query model, computed from the command's policy in the database, so a screen enables a row's button from the row itself — and inside a live query that is decided again on every answer. A command that takes longer than the server's sync window is answered Pending, followed to its end on the same response, and listed in a pending-work panel. What it wrote reaches the screen through the live queries that read it. Commands are off until a server sets MaxPendingCommands, and can be carried to a worker over NServiceBus, MassTransit, Rebus or Wolverine. See Commands.
An opt-in, GraphiQL-style explorer ships in Scry.Server.Explorer. It runs Roslyn in the browser, giving real IntelliSense and diagnostics against the allow-listed schema, and shows exactly what goes on the wire:
app.MapScryExplorer("/scry");It is off unless mapped, and Development-only by default. A query builder beside the editor shows the query as rows — columns, filters, sorts, paging — and writes back whatever is changed there, for building a query without typing one. See Query explorer.
A Blazor client has a companion: a debug sidecar that opens over the running app (Alt+Q) and shows every Scry exchange the page has made — decoded requests, pretty-printed responses, headers, and a one-click jump into the explorer with the captured query pre-populated.
- Getting started
- Comparisons
- Annotations
- Source generator
- The model as a package
- F#
- Writing queries
- Server
- Row policies
- Attachments
- Batching
- Live queries
- Commands
- MCP
- Observability
- Caching and 304
- Performance
- Security model
- Wire format
- Schema versioning
- Query explorer
- Debug sidecar
- Sample
Ripple by Zach Bogart via The Noun Project



