Query pipeline and result contract
Model-bound execution
Section titled “Model-bound execution”IQueryPipeline performs a query by fully qualified name. Generated model-bound endpoints and hub subscriptions use it. Database/provider code remains responsible for producing data; Arc does not turn a database read into a Chronicle projection.
A non-success filter verdict stops execution before the query method. With Arc’s built-in (staged) filters, an authorization denial also stops before the query method’s dependencies are resolved from the container. It does not imply no collaborators ran: paging validation, performer lookup, and argument conversion occur before authorization, and a custom filter implementation that is not staged resolves dependencies before its filters run. Custom filters/dependencies are application code, not guaranteed side-effect-free.
A null method result can remain a successful model-bound result. For a stream, the pipeline passes the wrapper onward; transport handling decides how to consume and deliver values.
Controller-specific path
Section titled “Controller-specific path”MVC GET actions use QueryActionFilter, not this model-bound filter chain. The action filter establishes context, consults MVC model state, invokes the action, renders data, applies ordinary interception, and creates an Arc result. It recognizes streaming values through the controller adapter.
[AspNetResult] opts out. POST actions do not use the GET query action filter. Null data in a default wrapped MVC GET becomes "Null data returned", unlike model-bound null success. See controller return types.
Query context and renderers
Section titled “Query context and renderers”IQueryContextManager.Current exposes query identity, correlation, arguments, dependencies, paging, sorting, total count, and ReceivedAt. ReceivedAt is a DateTimeOffset captured when Arc receives the operation: before argument binding and authorization preparation at a model-bound HTTP endpoint, at public pipeline entry for direct calls, and separately for each hub subscribe operation. It is not network arrival time or time before application middleware; MVC action filters capture it after MVC binding. It remains the same through filters, performers, and Arc-owned replacement service scopes. During a decorated transport dispatch, sibling public pipeline calls (including a query followed by a command) share the transport receipt. A pipeline call started from inside another pipeline entry captures a new receipt and restores the outer value afterward. Work that starts a pipeline after the dispatch finishes captures a fresh receipt, even if its execution context was created during dispatch. ReceivedAt describes the current operation; do not treat it as persistent per-user state.
A validator resolved from the query’s service provider can inject Cratis.Arc.IOperationContextAccessor; its nullable ReceivedAt is the same value during the operation and null outside it. MVC-bound validators running during model binding, before the query action filter starts the receipt, also see null. Nested operations get their own value and restore the outer value. Register a custom TimeProvider before or after AddCratisArcCore() to control receipt time in tests; Arc uses TryAddSingleton<TimeProvider> for TimeProvider.System, so an app registration takes precedence regardless of order.
QueryableQueryRenderer handles runtime IQueryable values: it counts the filtered query, applies sorting, then Skip/Take. The provider controls database execution. Lists and arrays do not gain automatic slicing. See model-bound paging or controller paging.
Custom renderers implement IQueryRendererFor<T> with QueryRendererResult Execute(T query, QueryContext queryContext). They return total count plus data. Do not use an unverified renderer-order assumption as a security boundary; put access filtering before counting and paging so neither rows nor counts disclose unauthorized data.
Query filters
Section titled “Query filters”Model-bound filters implement IQueryFilter.OnPerform(QueryContext) and return Task<QueryResult>. Implement IAuthorizationQueryFilter for access decisions that must precede ordinary validation filters. Discovered filters are resolved from the operation’s service provider; authorization filters run first, then other filters, preserving discovery order within each group. This is not a registration-order guarantee.
| Built-in filter | Current behavior |
|---|---|
AuthorizationFilter | Evaluates the query’s authorization declaration: authentication, roles, and registered named policies, plus named schemes on the ASP.NET Core host |
FluentValidationFilter | Validates a matching argument model or individual supplied argument graphs |
DataAnnotationValidationFilter | Reads annotations on parameter types, not method-parameter attributes or nested DTO properties |
Use QueryResult.Unauthorized(context.CorrelationId) for denial, not an input-validation error. QueryResult.Success(...) permits the next stage; it does not supply the final read data. The query-health restriction example is a complete authorization filter that targets one named query across direct model-bound and hub paths.
For input rules, follow query validation. For roles, policies, and schemes on queries, see model-bound authorization and authorization.
Compose outside AddCratisArc
Section titled “Compose outside AddCratisArc”If your host builds its own service collection, register the pipeline and its authorization-principal scope alongside your query services. This is a registration fragment for an existing IServiceCollection named services, not a standalone host:
using Cratis.Arc.Authorization;using Cratis.Arc.Queries;using Microsoft.Extensions.DependencyInjection;
services.AddTransient<AuthorizationDeclarations>();services.AddTransient<AuthorizationPrincipalScope>();services.AddTransient<IQueryPipeline, QueryPipeline>();AuthorizationDeclarations resolves each query’s authorization declarations, including fallback requirements, from IInstancesOf<> of the anonymous, attribute and fallback evaluators, so register Cratis type discovery (IInstancesOf<>) as well; without it the pipeline cannot authorize and fails. Supply the pipeline’s constructor services: ICorrelationIdAccessor, IQueryContextManager, IQueryFilters, IQueryPerformerProviders, IQueryRenderers, IReadModelInterceptors, IDiscoverableValidators, and IActivitySource<QueryPipeline>. Supply CurrentPrincipalAccessor, IAuthorizationPolicyRuntime, TenantIdAccessor, and ITenantIdResolver for AuthorizationPrincipalScope; the two concrete accessors also need IHttpRequestContextAccessor and ITenantIdResolver, respectively. Register a runtime appropriate to your host; ArcAuthorizationPolicyRuntime does not authenticate named schemes. Pass an execution-scoped IServiceProvider to IQueryPipeline.Perform. The pipeline resolves AuthorizationPrincipalScope only when authorization supplies a selected principal, so a successful ordinary query does not prove that the scope is registered. Begin remains internal; host code should not manipulate it directly.
If you want all Core services rather than manual composition, IServiceCollection.AddCratisArcCore() registers these services and the rest of Arc Core. It does not configure the ASP.NET Core host or map endpoints; see Core configuration.
Query result metadata
Section titled “Query result metadata”The backend Cratis.Arc.Queries.QueryResult is nongeneric, with object Data. Generated TypeScript query results can be typed; that does not create a C# QueryResult<T> API. Normal query methods return data rather than constructing this infrastructure envelope.
| JSON field | Meaning |
|---|---|
data | Returned data; can be null, and can be omitted/null on later hub delta frames |
paging | page (int), size (int), totalItems (long), totalPages (computed int) |
correlationId | Correlation identifier |
isReady | Whether a result is available; false is distinct from failure |
isAuthorized | Authorization verdict |
isValid | True when validationResults is empty |
hasExceptions | True when exceptionMessages is nonempty |
isSuccess | isReady && isAuthorized && isValid && !hasExceptions |
validationResults | Validation entries with severity, message, and members |
exceptionMessages, exceptionStackTrace | Error information, subject to the transport/host’s exception-detail handling |
changeSet | Optional collection delta; see transfer modes |
PagingInfo is PagingInfo(int Page, int Size, long TotalItems). totalPages is zero for size zero, otherwise Ceiling(totalItems / size). PagingInfo.NotPaged is (0, 0, 0) and is used for ordinary unpaged pipeline results. Observable snapshots and streaming emissions can instead report the query context’s total count with page and size zero. totalPages remains zero when size is zero. There are no hasNext, hasPrevious, or response pageSize fields.
Illustrative complete success envelope under camelCase serialization (the identifiers and data are examples, not a captured server response):
{ "data": [{ "id": "account-1", "name": "Savings", "balance": 100 }], "paging": { "page": 0, "size": 1, "totalItems": 2, "totalPages": 2 }, "correlationId": "12345678-1234-1234-1234-123456789012", "isReady": true, "isSuccess": true, "isAuthorized": true, "isValid": true, "hasExceptions": false, "validationResults": [], "exceptionMessages": [], "exceptionStackTrace": "", "changeSet": null}Default direct model-bound HTTP maps success to 200, authorization denial to 403, validation failure to 400, not-ready to 202, and other failures to 500. Observable waiting also has a 408 timeout path. Hub frames and SSE control POSTs have their own protocol semantics; do not infer their status from this table.
This envelope and this status mapping are part of Arc’s shared HTTP contract and are identical on the JVM backend, down to the precedence between authorization, validation, and readiness. What a client must not assume is identical is covered in where the implementations differ - in particular observable snapshot readiness and unknown fields in the QUERY envelope.
Streaming boundary
Section titled “Streaming boundary”An ISubject<IEnumerable<T>> is not automatically paged: MongoDB Observe() implements paging using the context; arbitrary subjects must supply their own behavior. Streaming delivery applies interception per emission, while observable HTTP snapshots currently do not. Read interception limitations before relying on masking, and observable lifetime before composing streams.