Skip to content

HTTP contract

Arc ships two backend implementations: C# on ASP.NET Core and Kotlin and Java on Spring Boot. They are one product because they speak one wire protocol. A TypeScript client generated from a C# backend has to work against a JVM backend, and the same fetch call has to mean the same thing on both.

This page is that protocol. Every statement here is true of both implementations. Where behavior genuinely differs, it is not on this page - it is in 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/commandsGETAnonymous command introspection metadata.
/.cratis/queriesGETAnonymous query introspection metadata.
/.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/usersGETAnonymous development-user discovery; returns an array, or [] when no provider contributes.
/.cratis/tenantsGETAnonymous development-tenant discovery; returns an array, or [] when no provider contributes.

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. desc selects descending order and asc selects ascending, case-insensitively - see the divergence note for the longer spellings.

Over GET the same request is expressed with reserved query-string parameters page, pageSize, sortBy, and sortDirection; 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 anonymous endpoints.

/.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.

A successful response also sets a .cratis-identity cookie holding the Base64-encoded response JSON. The cookie is deliberately client-readable (HttpOnly=false) so frontend code can read the current identity without a round trip; it is SameSite=Lax with Path=/. Its Secure attribute is set for an HTTPS request on both implementations; the JVM additionally exposes a policy that can force or suppress it, documented in the JVM conformance notes.

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.

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.

SSE subscribe and unsubscribe do not verify connection ownership on .NET

Section titled “SSE subscribe and unsubscribe do not verify connection ownership on .NET”

This is the one to read first.

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

  • C#: the handlers look the connection ID up and return 404 only when no such connection exists. There is no comparison between the POST caller and the principal that opened the GET stream. A caller holding another connection’s ID can attach a subscription whose results are written into that other caller’s SSE stream, and can unsubscribe that caller’s subscriptions. The injected subscription is authorized as the POST caller - the principal is captured from the POST request, not borrowed from the stream - so this is cross-connection injection and denial of service, not privilege escalation.
  • 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 mismatch returns 404, the same status as an unknown connection, so the endpoint does not confirm that someone else’s connection ID exists.

Why it matters: do not treat a connection ID as a bearer-style secret on either implementation, but on C# in particular do not let one leak - through a log, a referrer, or a shared diagnostic - and do not build a client that assumes a subscription it did not create cannot appear on its stream.

/.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 also not uniformly callable. C# query introspection builds the conventional route and does not apply an explicit [Path], so a query with a custom path is introspected under a route that is not the one it answers on.

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 will miss C# queries with custom paths. Neither implementation exposes a default expression or an invented default value.

  • C#: only desc (case-insensitive) selects descending. Every other value - including descending - falls through to ascending, silently.
  • JVM: asc, ascending, desc, and descending are all accepted case-insensitively, and anything else is rejected as malformedRequest with 400.

Why it matters: sortDirection=descending sorts the wrong way on a C# host and is rejected outright on a JVM host. Send asc and desc; they are the only two values that mean the same thing everywhere.

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.

  • 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.