Skip to content

HTTP contract

This page describes the shared wire contract verified for C# on ASP.NET Core and Kotlin and Java on Spring Boot. It covers the common routes, envelopes, headers, and statuses for clients targeting either host.

The common sections apply to both implementations. Where behavior differs, see Where the implementations differ, which names both behaviors so a client author can see the edge before hitting it.

EndpointMethodContract
Command routePOSTJSON command body; returns a command result envelope.
<command-route>/validatePOSTRuns authorization and validation without invoking the handler.
Query routeGETArguments in query-string parameters; returns a query result envelope.
Query routeQUERYStructured JSON request body; enabled by default; responds with Cache-Control: no-store.
Observable query routeGETHTTP snapshot, direct SSE when the request accepts text/event-stream, or a direct WebSocket upgrade.
/.cratis/queries/wsWebSocketMultiplexed observable-query hub.
/.cratis/queries/sseGETMultiplexed SSE hub; the stream opens with a Connected message carrying the connection ID.
/.cratis/queries/sse/subscribePOSTAdds or revision-replaces a subscription on an established SSE connection.
/.cratis/queries/sse/unsubscribePOSTCancels a subscription or records its revision tombstone.
/.cratis/commandsGETCommand introspection metadata. Access differs by backend; see the divergence note.
/.cratis/queriesGETQuery introspection metadata. Access differs by backend; see the divergence note.
/.cratis/queries/healthGET, QUERYCurrent observable connection and subscription health, served as an observable query.
/.cratis/meGETRegistered only when an identity details provider is registered.
/.cratis/identity-details/schemaGETAlways registered; returns {} when no provider is registered.
/.cratis/usersGETDevelopment-user discovery; returns an array, or [] when no provider contributes. Access differs by backend; see the divergence note.
/.cratis/tenantsGETDevelopment-tenant discovery; returns an array, or [] when no provider contributes. Access differs by backend; see the divergence note.

Conventional routes are built from the configured route prefix (api by default), the artifact’s namespace with the configured number of leading segments skipped, kebab-cased, and the artifact name. An explicitly declared path is preserved exactly. The QUERY method is registered alongside GET for every query route, including observable ones, and can be switched off so query routes accept GET only.

HeaderBehavior
X-Correlation-IDThe correlation header, configurable. A valid UUID is reused; a missing or invalid value is replaced. The effective UUID is echoed on the response under the configured name. See Correlation.
X-Allowed-SeverityThe maximum nonblocking validation severity for the request. See the divergence note before relying on it.
x-cratis-tenant-idThe tenant header, configurable. See Tenant resolution.

Header lookup is case-insensitive, as HTTP requires.

Correlation is diagnostic identity. It never carries authority, and no part of the contract grants a caller anything on the strength of a correlation value.

  • An inbound correlation header value is reused only when it is a syntactically valid UUID. Anything else - including an empty value or arbitrary text - is replaced by a freshly generated identifier.
  • The effective value is always emitted in canonical UUID form, so client-supplied text never reaches a response header.
  • Every Arc response carries the configured correlation header.
  • The same value appears as correlationId in the command and query result envelopes, so a caller can correlate a response body with its transport header without parsing anything.

The QUERY request body carries what a GET would put in the query string:

{
"arguments": { "name": "Ada" },
"paging": { "page": 0, "pageSize": 25 },
"sorting": { "field": "name", "direction": "desc" }
}

arguments, paging, and sorting are all optional. paging takes page (zero-based) and pageSize; sorting takes field and direction. A QUERY response always carries Cache-Control: no-store.

Argument names are matched case-insensitively against the query’s declared parameters, and a matched value is converted to the parameter’s declared type. asc, ascending, desc, and descending all select their direction case-insensitively, and any other value is rejected as malformedRequest with 400 rather than being sorted some other way.

Over GET the same request is expressed with reserved query-string parameters page, pageSize, sortBy, and sortDirection. These four reserved keys are matched case-insensitively (sortby and SORTBY also work); every other parameter is an argument.

A command body is deserialized into the command type by the host’s configured JSON reader. Neither implementation turns on strict unknown-property rejection for that reader, so a command body carrying a field the command does not declare executes the command with the extra field discarded. {"value":"hello","unexpected":"extra"} is accepted, and unexpected is dropped.

This holds for the command route and its /validate sibling alike. The QUERY envelope is stricter on one implementation only - see Unknown fields in the QUERY envelope and in query arguments.

An observable query is a query whose result is a stream rather than a value. The same route serves three transports, selected by the request.

A plain GET returns the stream’s current value as an ordinary query result with 200 when one can be read. When the stream has not yet produced a value, the response is a not-ready query result - isReady: false, no exception - with 202 Accepted. A caller that would rather wait sends waitForFirstResult=true, optionally with waitForFirstResultTimeout in seconds, and the host waits for the first result within a bounded budget. Both parameters are reserved: they are never treated as query arguments.

While waiting, expiry of that budget returns 408 Request Timeout with an error envelope, and a stream that completes without ever producing a value returns 500.

Not-ready is a transient state, not a failure. hasExceptions stays false so a client does not read a pending stream as a crash.

A GET whose Accept header includes text/event-stream upgrades the same route to Server-Sent Events. Each emission is written as exactly:

data: {query result envelope}

That is the data: field prefix, a space, the JSON envelope, and two newlines. There is no event name and no id: field.

A WebSocket upgrade on the same route carries a message envelope with a string type of Data, Ping, or Pong. A Data message carries the query result envelope in data; Ping and Pong carry a Unix-millisecond timestamp.

/.cratis/queries/ws and /.cratis/queries/sse multiplex many subscriptions over one connection. Hub messages use a shared envelope with exact PascalCase type values: Subscribe, Unsubscribe, QueryResult, Unauthorized, Error, Ping, Pong, and Connected, alongside queryId, revision, payload, timestamp, keepAliveIntervalMs, and supportsSubscriptionRevisions.

A hub connection opens with a Connected message whose payload is the server-assigned connection ID, and which carries keepAliveIntervalMs and supportsSubscriptionRevisions: true. SSE clients pass that connection ID on the subscribe and unsubscribe POST bodies to correlate them with the stream.

A subscribe payload is flat:

{
"connectionId": "…",
"queryId": "client-assigned",
"revision": 1,
"request": {
"queryName": "MyApp.Authors.Listing.AllAuthors",
"arguments": { "name": "Ada" },
"page": 0,
"pageSize": 25,
"sortBy": "name",
"sortDirection": "desc",
"transferMode": "delta"
}
}

arguments is a map of string values. revision is optional; when present it must be a positive integer no greater than 9007199254740991, the JavaScript safe-integer limit, and it orders operations for one queryId so a duplicate is idempotent, a stale message is ignored, and an unsubscribe tombstone can arrive before a delayed subscribe. Result and terminal messages echo queryId and revision.

transferMode is matched case-insensitively. full sends each snapshot without a change set; delta sends a full first snapshot and then only a change set. It is a preference about how much of each snapshot travels, not part of what the subscription means, so a textual value outside the known set is served exactly as one that sent no transferMode at all: snapshot plus change set on every result.

Each subscription is authorized independently by the ordinary query pipeline. A subscription the caller may not have terminates with an Unauthorized message for that queryId without disturbing the connection’s other subscriptions. An unknown SSE connection ID returns 404.

A tenant is resolved per request from configured sources - most commonly the x-cratis-tenant-id header, with a query parameter, the request host, a principal claim, or a fixed value as alternatives. The resolved tenant is established for the request before the command or query runs, and applies to HTTP requests, SSE subscriptions, and WebSocket handshakes alike.

When authentication handlers are registered, they run in order and the first handler that recognizes the request decides the outcome:

  • An authenticated result supplies the request principal, and the chain stops.
  • A failed result is terminal. No later handler runs, and no later handler can override the rejection with a success. The failure surfaces only as a generic 401.
  • An anonymous result means the handler did not recognize the request and lets later handlers try. Anonymous is the final outcome only when no handler recognized the request at all.

An artifact marked to allow anonymous access proceeds without an authenticated result. The introspection, development-user, development-tenant, and identity-schema routes are the discovery routes. The JVM serves them anonymously; the C# host serves them anonymously only in Development by default (see Introspection access is configurable on .NET).

/.cratis/commands returns, for each command, its name, namespace, route, type, a single-line documentation summary taken from the source language’s doc comments (empty when the artifact carries none), and a JSON schema for the payload. /.cratis/queries returns the same for each query plus its fully qualified query name and an arguments schema whose required list holds the parameters the query actually requires.

FieldTypeNotes
correlationIdUUIDAlways present.
isAuthorizedBooleanAuthorization outcome.
validationResultsArrayAlways present, including when empty.
exceptionMessagesArrayAlways present; production redaction replaces detail.
exceptionStackTraceStringEmpty when redacted or absent.
authorizationFailureReasonStringEmpty when absent.
isValidBooleanTrue only when validationResults is empty, regardless of severity.
hasExceptionsBooleanTrue when exceptionMessages is nonempty.
isSuccessBooleanAuthorized, valid, and exception-free.
responseAnyOmitted when null. A failed result never carries a response.

isValid, hasExceptions, and isSuccess are derived, not independent inputs. A response produced by a handler is taken back before serialization when the command did not ultimately succeed, so a client never receives a value it must not act on alongside a failure.

Outside Development, exception messages and stack traces are replaced by a generic message before serialization. The full detail is logged server-side first, and the correlation ID is retained, so hasExceptions - and therefore the status code - is unchanged by redaction.

FieldTypeNotes
correlationIdUUIDAlways present.
dataAnyThe query data; omitted when null.
isReadyBooleanFalse for an observable query that has not produced its first result.
isAuthorizedBooleanAuthorization outcome.
validationResultsArrayAlways present, including when empty.
exceptionMessagesArrayAlways present; production redaction replaces detail.
exceptionStackTraceStringEmpty when redacted or absent.
pagingObjectAlways present.
changeSetObjectPresent only for a delta-mode observable emission.
isValidBooleanTrue only when validationResults is empty.
hasExceptionsBooleanTrue when exceptionMessages is nonempty.
isSuccessBooleanReady, authorized, valid, and exception-free.

paging carries page (zero-based), size, totalItems, and a calculated totalPages that is zero when size is zero and otherwise totalItems divided by size, rounded up.

When changeSet is absent, data is the complete current snapshot.

ConditionStatus
Successful command or query200
Query not ready202
Validation failure, malformed request, or an unresolvable command dependency400
Authentication failure401
Command or query authorization failure403
Observable wait for the first result timed out408
Pipeline or host exception500

Status selection is ordered, and the order matters when a result carries more than one kind of failure: success first, then authorization failure (403), then validation failure (400), then not-ready (202), then exception (500). A failed result never exposes a response value.

A malformed or wrong-typed request body is a client error, not a server fault: it becomes a validation failure carrying the malformedRequest reason and returns 400 without echoing the underlying parser message. The same applies to a value that cannot be bound to the member it was sent for.

/.cratis/me returns 401 for an unauthenticated principal, 403 when the details provider rejects the caller, and 200 on success with:

{
"id": "…",
"name": "…",
"isAuthenticated": true,
"isAuthorized": true,
"roles": ["…"],
"details": { }
}

details is application-specific and its shape is the one described by /.cratis/identity-details/schema.

The identity is always derived from the authenticated principal of the request. Clients read it from this response body; neither the C# nor the JVM implementation treats an identity cookie sent by the client as input. Whether a successful response also sets a .cratis-identity cookie differs between the implementations - see the identity cookie. Arc’s frontend packages read it only when /.cratis/me answers 404, as a transitional fallback.

Every validation result carries:

FieldTypeNotes
severityNumberSee the severity table below.
messageStringA developer-facing diagnostic, free to change.
membersArray of stringThe member names the result applies to.
stateAnyOptional, application-supplied.
reasonStringWhat composed the result. Defaults to rule.
reasonDetailStringOptional finer identity within reason.
SeverityWire value
Unknown0
Information1
Warning2
Error3

reason is an open set, not an enum. Rejections are composed in Arc, in Chronicle, and in application code, and a closed set would make every new kind a breaking change for whoever switches over it. A client must tolerate values added later. The current values are:

ReasonMeaning
ruleAn application-authored rule rejected the input. The message is the author’s; show it.
concurrencyViolationThe target moved on since it was read. Retryable: re-read and resubmit.
constraintViolationA store-level constraint rejected the write.
validatorFailedA validator threw, and the framework substituted a result. Nothing of the authored rules survives.
dependencyUnavailableSomething the command needed was not available, so no rule was ever evaluated.
malformedRequestThe request itself could not be read or bound. No rule was reached.
frameworkFailureThe framework could not fulfill a command contract or complete its effects. Framework-composed; the message is not meant for end users.

reason is the machine-readable counterpart to message, and reasonDetail says which specific thing within that category produced the result - which constraint, for example - so a client can branch on identity instead of matching prose.

isValid is false whenever validationResults is nonempty, regardless of severity. Severity filtering happens before results reach the envelope, driven by the X-Allowed-Severity header and the artifact’s own settings; anything that survives that filter is blocking.

message, members, state, reason, and reasonDetail are all client-visible and are not redacted the way exception detail is. Keep secrets out of every field.

Everything above is common. The following is not, and each entry matters to someone writing a client or reasoning about security. Each one names what the C# implementation does and what the JVM implementation does. These comparisons do not claim TypeScript parity: for the source preview’s verified behavior and deliberate differences, use its HTTP contract reference and capability reference.

Section titled “The identity cookie is only written by the JVM”
  • C#: /.cratis/me does not set a cookie. It ignores any .cratis-identity cookie the client sends, and expires it. Earlier versions wrote a client-readable cookie and trusted it ahead of the principal; see migrating from the identity cookie.
  • JVM: a successful response sets a client-readable (HttpOnly=false) .cratis-identity cookie holding the Base64-encoded response JSON, SameSite=Lax with Path=/, and a Secure policy documented in the JVM conformance notes. It is not used to establish the principal.

SSE connection ownership is compared on a different set of values

Section titled “SSE connection ownership is compared on a different set of values”

Both implementations verify that the caller of an SSE control POST is the caller that opened the GET stream, and both answer a mismatch with 404 - the same status as an unknown connection, so neither endpoint confirms that someone else’s connection ID exists. What they compare is not the same.

The SSE control endpoints take a connectionId in the POST body and act on the stream it names.

  • C#: the identity that opened the GET stream is captured once, while that request is still in flight, and each subsequent POST is compared against it on the identity identifier claim and authentication state. The display name and the resolved tenant are not compared: the name is mutable and a control POST is authorized with its own tenant rather than the connection’s.
  • JVM: the handshake captured when the GET stream opened is compared with the handshake of the POST request on principal ID, principal name, authentication state, and resolved tenant.

A connection opened by an unauthenticated caller carries no identity to bind to on either implementation, so ownership cannot be distinguished between anonymous callers.

Why it matters: a client may not assume a control POST issued under a different identity will be honored, and may not treat 404 as proof that a connection does not exist. A connection whose caller has been re-authenticated under the same identity - a token refresh while the stream stays open - keeps working on C#; on the JVM a change to the principal name or the resolved tenant ends it. Do not treat a connection ID as a bearer-style secret on either implementation.

/.cratis/queries/health reports connection and subscription identifiers, remote IP addresses, user agents, and user identities.

  • C#: the health read model is declared [AllowAnonymous], so the endpoint is reachable without credentials.
  • JVM: the route requires an authenticated caller whenever authentication handlers are configured.

Why it matters: a client may not assume the endpoint is protected. On a C# host, restrict the path at ingress if the diagnostics are sensitive.

Both implementations return 200 when a current value can be read and 202 when it cannot, but what counts as readable differs with the stream type each runtime uses.

  • C#: the host reflects for a Value property on the streaming result. A BehaviorSubject-shaped observable exposes one and returns 200; a MongoDB-backed subject does not, so a GET returns 202 even when data exists in the collection.
  • JVM: a StateFlow already holds a value and returns 200; a cold Flow or a JDK Flow.Publisher has nothing to serve yet and returns 202.

Arc’s own /.cratis/queries/health is a worked example: on C# it is an observable query whose returned subject exposes no Value property, so a plain GET answers 202 with a not-ready envelope even though health data exists. Accept text/event-stream, upgrade to WebSocket, or pass waitForFirstResult=true to read it over HTTP.

Why it matters: a client that treats 202 as an error will break against perfectly healthy backends. Treat 202 as “subscribe, or retry with waitForFirstResult=true”.

  • C#: command metadata is name, namespace, route, type, documentationSummary, and payloadSchema. Query metadata is the same plus fullyQualifiedName and argumentsSchema. Authorization, per-property validation metadata, transport, paging support, and HTTP-method preference are not reported.
  • JVM: reports the above and additionally authorization, properties, and validation metadata for commands, and transport, paging support, and HTTP preference for queries. Query parameter metadata reports hasDefault for a defaulted Kotlin parameter, and the arguments schema excludes defaulted parameters from required. UUID and textual java.time terminals - LocalDate, LocalTime, LocalDateTime, Instant, OffsetDateTime, ZonedDateTime, OffsetTime, Duration, and Period - appear as scalar string schemas, including as collection elements, rather than object schemas.

The reported route is not necessarily callable. C# query introspection applies an explicit [Path] if present, but it does not reflect all final route deduplication or replacement decisions.

Why it matters: tooling built against the JVM’s richer metadata degrades rather than fails on a C# host, but tooling that requires hasDefault or authorization metadata will find it absent, and tooling that treats an introspected route as a callable URL may miss routes replaced during mapping. Neither implementation exposes a default expression or an invented default value.

Unknown fields in the QUERY envelope and in query arguments

Section titled “Unknown fields in the QUERY envelope and in query arguments”
  • C#: the QUERY envelope is deserialized permissively. An undeclared envelope field is ignored, and a query argument whose name matches no declared parameter is carried through as a raw string rather than rejected.
  • JVM: Arc validates the envelope’s field set itself - only arguments, paging, and sorting, with only page/pageSize and field/direction inside them - and rejects an undeclared field with 400 and malformedRequest. An argument name matching no declared parameter, and two argument names colliding after case folding, are rejected the same way. Page values must be nonnegative.

Why it matters: a client typo in the envelope or an argument name fails loudly on a JVM host and silently on a C# host. Do not rely on either behavior; send exactly the fields the query declares.

  • C#: the header is read on the command route only, and only as an integer. A non-numeric value such as Warning is silently ignored, and so is an out-of-range number.
  • JVM: the header is accepted as a case-insensitive severity name or as the numeric wire value, on commands, queries, and hub subscriptions. A value that parses as neither is rejected as malformedRequest.

Why it matters: send the numeric wire value - 0, 1, 2, or 3 - if the header has to work on both. Do not send a name, and do not assume it affects queries.

  • C#: there is no connection or subscription admission control and no request-body size gate in Arc itself. 429, 503, Retry-After, and 413 are not produced by Arc; any such response comes from the surrounding host or infrastructure.
  • JVM: the host bounds concurrent Arc operations, physical observable connections, subscriptions per connection, outbound frames, inbound WebSocket message bytes, connection lifetime, and retained revision tombstones. Exhaustion fails closed: 503 with Retry-After for connection or request admission, 429 for SSE subscription exhaustion. A command or QUERY body exceeding the configured byte limit returns 413, counted while streaming even when Content-Length is absent.

Why it matters: a client that handles 429 and 503 with backoff works on both; a client that does not will appear to work against C# and then fail under load against a JVM host.

  • C#: under ASP.NET Core hosting, a matched route with an unsupported method returns 405 with an Allow header, from ASP.NET Core routing. Under Arc’s self-hosted HttpListener host, routes are keyed by method and path together, so an unsupported method falls through to 404.
  • JVM: an unsupported method returns 405 with an Allow header.

Why it matters: do not use 405 versus 404 to probe whether a route exists.

Tenant resolution strategy and enforcement

Section titled “Tenant resolution strategy and enforcement”
  • C#: one resolver strategy is selected by configuration - header, query parameter, subdomain, claim, fixed, or development. There is no option to require a resolved tenant, so an unresolved tenant does not itself fail the request.
  • JVM: a configured resolver chain runs once per HTTP request, SSE subscription, or WebSocket handshake, over headers, query parameters, the server host, and captured principal claims. With tenancy.required=true an unresolved request returns 400, or fails the WebSocket handshake. An authenticated caller with tenant-membership claims who selects another tenant receives a generic 403.

Why it matters: a client cannot infer tenancy enforcement from the protocol. Send the tenant header explicitly rather than relying on host-side inference.

  • C#: Arc’s own IAuthenticationHandler chain is invoked by the self-hosted HttpListener host. Under ASP.NET Core hosting, authentication is ASP.NET Core’s own, and Arc’s anonymous metadata maps to ASP.NET Core’s allow-anonymous metadata.
  • JVM: Arc’s authentication filter runs the registered AuthenticationHandler and AsyncAuthenticationHandler beans for Arc routes, ordered ahead of the rest of the Arc pipeline and behind Spring Security’s chain.

Why it matters: the 401 a client sees is the same either way, but where credentials are validated - and therefore which configuration governs them - is not.

Introspection access is configurable on .NET

Section titled “Introspection access is configurable on .NET”
  • C#: Cratis:Arc:Introspection controls the discovery routes: /.cratis/commands, /.cratis/queries, /.cratis/users, /.cratis/tenants and /.cratis/identity-details/schema. Enabled (default true) maps or unmaps the two catalog routes. IdentityDiscovery (default true) maps or unmaps users, tenants and the identity-details schema. Set both switches to false to remove all discovery routes without discovery authentication enforcement checks or exposure warnings; /.cratis/me is unchanged. RequireAuthentication, when not set, serves the mapped discovery routes anonymously in Development and requires an authenticated caller elsewhere; outside Development a host that cannot authenticate callers leaves them unmapped (404). true requires authentication everywhere and false exposes them anonymously everywhere. An anonymous request to a protected route returns 401 on Arc.Core. Roles (default unset, implies authentication) makes a caller without any listed role receive 403 on Arc.Core. On ASP.NET Core, the response depends on the authentication scheme’s challenge or forbid behavior (for example, cookies can redirect). Unsigned forwarded identity headers (x-ms-client-principal*) authenticate nothing, on the catalog or anywhere else, until the host sets Cratis:Arc:TrustForwardedIdentityHeaders (default false); a request carrying only those headers is anonymous and gets 401 from a protected catalog. The older Introspection:TrustForwardedIdentityHeaders is an obsolete alias that turns on the host-wide setting.
    • A custom handler that relies on forwarded headers must check the same opt-in itself and return an anonymous result without it, so the request returns 401.
    • With no authentication handler at all, a request to an endpoint marked as requiring authentication fails with AuthenticationRequiredWithoutHandlers rather than proceeding.
  • JVM: the introspection routes are always anonymous; there is no option to unmap or protect them.

Why it matters: a client or tool reading the catalog can get 401 or 403, or a missing route, from a C# host but not from a JVM host. Only opt in to forwarded identity headers behind trusted ingress that authenticates callers, strips client-supplied identity headers, and prevents direct backend access.

  • C#: the all-zero UUID is treated as not-set and replaced with a generated identifier. Correlation is established by the Arc endpoint handlers, so it covers Arc routes.
  • JVM: a servlet filter registered on /* establishes one identifier for every request reaching the host, whether Arc owns the route or not, ordered ahead of Spring Security and Arc authentication. The all-zero UUID parses as a UUID and is reused.

Why it matters: do not send the zero UUID and expect it back. The identifier you observe on a C# host may differ from the one you sent in exactly that case.

  • C#: /.cratis/users and /.cratis/tenants concatenate the results of every registered provider in discovery order, without deduplication.
  • JVM: results are aggregated from ordered provider beans and deduplicated by identifier - the first item for each identifier wins.

Why it matters: a client rendering a development user picker may see duplicate identifiers on a C# host if two providers contribute the same one.