Persisted Operations
Persisted operations decouple a build-time-stable id (e.g. userProfile_v1) from the GraphQL document text the server executes for it. The mobile client (or any shipped consumer) sends only the id; the server holds a manifest mapping ids to documents and can rewrite a document without touching the client binary.
The motivating use case is mobile: a buggy query baked into an iOS or Android build is functionally permanent until the next app-store release. With persisted operations, the server can hot-fix any change that does not alter the response shape (filter fixes, sort direction, pagination size, resolver-path swaps, performance rewrites).
The contract
client: { id: "userProfile_v1", variables: { userId: 42 } }
server: looks up "userProfile_v1" -> "query UserProfile($userId: Int!) { ... }"
server: executes the resolved document, returns responseThe contract becomes (operationId, variables) -> response shape. As long as the JSON shape stays compatible with what shipped clients expect, the document text is fair game.
What is hot-fixable
| Fixable | Example |
|---|---|
Wrong where filter | { status: { eq: "active" } } -> { and: [{ status: { eq: "active" } }, { deleted: { eq: false } }] } |
Wrong order direction | [{ created: ASC }] -> [{ created: DESC }] |
Wrong default first / pagination | first: 10 -> first: 25 |
| Resolver-path swap | discover { campaigns { ... } } -> discover { models { campaigns { ... } } } |
| Performance rewrite | Replace a custom train with an equivalent model query |
| Adding non-output args / variables | New optional variables (old clients ignore, new clients pass) |
What requires a client redeploy
| Not fixable | Why it breaks |
|---|---|
| Adding a field the UI needs | Old clients don't know to read it |
| Renaming or removing a field | Client deserializer expects the old name |
| Changing a field's nullability to required | Old clients may send no value or null |
| Changing a variable's type | Server rejects the old type |
The shape-diff guardrail enforces this contract on every edit (see Shape-Diff Guardrail below).
Versioning
Ids are opaque strings. There is no parse rule - userProfile_v1, userProfile, or userProfile-2026-05 are all valid. The convention <name>_v<N> is recommended for readability but not enforced.
The version field on the row is operator-controlled metadata, set via UpsertOptions.Version (or the version input on the GraphQL mutation). It is not used for request routing - the id is the contract with shipped clients. Bump the version when shipping a new client that requests a new id; both ids coexist in the database for the rollover period.
The convention is built-time stable, not content-derived. Apollo's automatic-persisted-queries (APQ) hash the document text and produce a different id whenever the text changes, defeating the hot-fix property; persisted operations do the opposite.
An id runs only the document the store holds for it
A request names an id, a document, or both. With the id alone, the server runs the stored document, or refuses with HC0020 when the id is unknown or deactivated. With the document alone, the server treats it as inline and enforcement decides. With both, the server refuses the request with PERSISTED_OPERATION_ID_MISMATCH (HTTP 400) unless the id is the document's own hash, under the executor's hash algorithm. That is the same check APQ makes before it trusts a client-supplied hash, so a client sending an id with the document it hashes from keeps working. HotChocolate hashes with MD5 hex by default; for Apollo-style SHA-256 ids, call AddSha256DocumentHashProvider() on the schema.
Setup
The minimum configuration enforces persisted-only requests on a host that runs one node:
using Trax.Api.GraphQL.Extensions;
using Trax.Api.GraphQL.PersistedOperations.Extensions;
builder.Services.AddTraxGraphQL(graphql => graphql
.AddDbContext<ClientDataContext>()
.UsePersistedOperations(opts => opts
.UseDatabase(builder.Configuration.GetConnectionString("Trax")!)
.RequirePersisted(true)
.SingleNode()
)
);
var app = builder.Build();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseTraxGraphQL();Enforcement runs inside HotChocolate's execution pipeline, after the document is parsed and before it is validated, so it needs no ASP.NET middleware and covers every transport the same way: a JSON POST, a GET, a multipart POST, a WebSocket subscribe, and a request your own code builds in-process. UsePersistedOperationsEnforcement() still compiles for existing hosts and adds nothing.
Host code that builds a request itself and should run an inline document can say so with HotChocolate's own override:
var result = await executor.ExecuteAsync(
OperationRequestBuilder.New()
.SetDocument("{ hello }")
.AllowNonPersistedOperation()
.Build());No Trax transport sets it, so a remote caller cannot.
One node or many
Persisted operations refuse to start until you say how a change reaches every node. Each node caches the documents it serves, and HotChocolate's caches never expire, so an upload, deactivation or restore made on one node reaches another only if it is broadcast.
| Deployment | Call |
|---|---|
| One process serves the endpoint and writes the store | SingleNode() |
| More than one node, or the store is written from another process | UseRabbitMqInvalidation(rabbitConnectionString) |
Calling neither, or both, fails at startup with a message naming the fix. An existing single-node host adds .SingleNode() to its UsePersistedOperations(...) call; the samples and templates need the same one line.
SingleNode() is a claim nothing can check at runtime. A second node, or a CI uploader writing the store from its own process, makes it false: a change made there does not reach this node until it restarts.
.UsePersistedOperations(opts => opts
.UseDatabase(connectionString)
.RequirePersisted(true)
.UseRabbitMqInvalidation(rabbitConnectionString)
)The RabbitMQ broadcaster publishes a PersistedOperationChangedMessage on every upsert, deactivate, and restore. Each node binds an exclusive auto-delete queue to a fanout exchange (trax.persisted_operations.invalidation) and, on receipt, empties HotChocolate's caches and its Trax cache entry. When a node loses its broker connection it empties every cache, and empties them again when the connection recovers, because a change broadcast in between never reached it.
An uploader that writes the store from its own process broadcasts too when you register the store with the broker's connection string: AddPersistedOperationStore(databaseConnectionString, rabbitConnectionString).
With the Trax lookup cache
.UsePersistedOperations(opts => opts
.UseDatabase(connectionString)
.RequirePersisted(true)
.SingleNode()
.WithInMemoryCache()
)There are two cache layers. HotChocolate always caches the parsed document and the compiled operation for each id it serves, so even without this the database is read only the first time a node serves an id and after a change empties those caches. WithInMemoryCache() adds a second layer under them that caches the store's lookups, with a TTL (15 minutes by default) that bounds that layer only. Turn it on only when measurements show the lookup is hot. It is independent of UseRabbitMqInvalidation, which reaches both layers.
HotChocolate cache invalidation
Upsert, deactivate, and restore each clear HotChocolate's request-pipeline caches in addition to Trax's optional IPersistedOperationCache:
IDocumentCache(keyed by persisted-operation id) holds the parsedDocumentNode.IPreparedOperationCache(keyed by{schema}-{executorVersion}-{documentId}+{operationName}) holds the compiled operation.
Without this, a re-uploaded document text would be visible from IPersistedOperationStore.GetAsync but the request executor would keep serving the previously compiled operation until the process restarted. Cross-node invalidation via UseRabbitMqInvalidation(...) triggers the same HotChocolate cache clear on every receiver. A document sent inline is cached only under its own hash, never under an id the request names.
HotChocolate treats a persisted-operation id as immutable, so neither of its caches exposes per-id removal (and from version 16, no way to clear them at all). Enabling persisted operations therefore substitutes two cache implementations that can be emptied, with the same contracts and bounds as HotChocolate's own. Each invalidation empties them; persisted-operation edits are operator-driven and rare, so the cache-warm cost on the next handful of requests is acceptable. A host that does not use persisted operations keeps HotChocolate's caches untouched.
Phased rollout
A consumer flipping enforcement on for the first time will reject every shipped client that hasn't had its manifest uploaded. Use shadow mode to observe the gap before enforcing:
.UsePersistedOperations(opts => opts
.UseDatabase(connectionString)
.RequirePersisted(false) // do not reject
.LogNonPersistedRequests(true) // log everything that would be rejected
.SingleNode()
)Run for one full release cycle, confirm zero unexpected non-persisted requests in your logs, then flip RequirePersisted(true) on a canary environment, then prod.
Allowlist and dev carve-outs
Operation names that bypass enforcement (case-sensitive):
opts.AllowOperations("playground_smoke_test", "DevExplore")Predicate form for patterns:
if (builder.Environment.IsDevelopment())
opts.AllowOperationsMatching(id => id.StartsWith("dev_"));The allowlist is a convenience for trusted networks, not a security control. It matches the
operationNamethe caller puts in the request body (or, for an unnamed request, thedocumentIdorid), and it never looks at the document.AllowOperations("playground_smoke_test")admits any inline document with an operation of that name, whatever the operation selects, and thedev_predicate above admits any caller who starts a name withdev_. Use it for smoke tests and developer tools on a network you already trust. To admit a fixed document from a client you do not control, persist the document and have the client send its id: that is the control. Per-type[TraxAuthorize]still applies to allowlisted requests, as it does to every request, because enforcement shapes which documents run and is not the authorization boundary.
Introspection requests bypass enforcement automatically. A request is introspection when its document parses and every top-level selection is __schema, __type or __typename. Disable with DisableIntrospection() for tight prod.
Managing operations
There are three surfaces, all backed by the same IPersistedOperationStore and the same shape-diff and schema-validation guardrails. The dashboard and the GraphQL fields both go through IPersistedOperationsService, so they accept and refuse the same things with the same codes.
From the dashboard
When the host registers IPersistedOperationsService, the Trax dashboard exposes a Persisted Operations entry under Data. UsePersistedOperations(...) registers it on a GraphQL host, and AddPersistedOperationStore(...) on a host that serves no GraphQL, such as a dashboard running in a process of its own. The page lists trax.persisted_operation a page at a time, filters by tenant, status and id prefix, and offers Upload / Edit / Deactivate / Restore actions against the row's own tenant. It goes through the same IPersistedOperationsService as the operations.persistedOperations fields, so an upload or deactivation the API refuses is refused with the same message. The editor renders parse, schema-validation, and shape-diff errors inline so the operator never has to read a stack trace.
A dashboard in its own process should register the store with the broker's connection string, AddPersistedOperationStore(databaseConnectionString, rabbitConnectionString), so its writes are broadcast like any other uploader's.
If neither registration was made, the sidebar entry is hidden and direct navigation to /trax/data/persisted-operations, or to an operation's detail page under it, renders a "not enabled on this server" panel. The dashboard asks the container for IPersistedOperationsService; it does not need IPersistedOperationsCapability, which only UsePersistedOperations registers.
Via GraphQL mutations
Three mutations and three queries are added to the schema by UsePersistedOperations(...), under the operations.persistedOperations namespace (matching the convention for every other Trax management feature, like operations.manifestGroups and operations.deadLetters):
| Field | Purpose |
|---|---|
mutation operations.persistedOperations.uploadPersistedOperation | Insert or update an operation. Runs schema validation and the shape-diff guardrail. |
mutation operations.persistedOperations.deactivatePersistedOperation | Soft-delete; subsequent requests for the id resolve to null. Reason is required. |
mutation operations.persistedOperations.restorePersistedOperation | Reactivate a deactivated row. |
query operations.persistedOperations.persistedOperations | Paginated list. Filterable by active, tenant, id prefix. |
query operations.persistedOperations.persistedOperation | Single row by id. |
query operations.persistedOperations.persistedOperationHistory | Audit log, most-recent-first. |
Mutations never throw to the client. Failures come back as a payload errors[] entry with a stable code:
| Code | Meaning |
|---|---|
PARSE_FAILED | Document failed to parse. The error carries locations { line column }. |
SCHEMA_VALIDATION_FAILED | Document references a field, type, or variable the server schema does not have. The error carries one entry per failure with message and (when available) locations. |
SHAPE_DIFF_VIOLATION | The edit would change the response shape of an existing id. The error carries oldFingerprint and newFingerprint. Pass bypassShapeDiff: true when the change is verified safe. |
NOT_FOUND | Deactivate or restore against an unknown id. |
INVALID_INPUT | id or required fields were empty, or the document holds more than one operation. A persisted document holds exactly one. |
Example upload:
mutation Upload($input: UploadPersistedOperationInput!) {
operations {
persistedOperations {
uploadPersistedOperation(input: $input) {
success
operation { id shapeFingerprint isActive }
errors { code message locations { line column } oldFingerprint newFingerprint }
}
}
}
}{ "input": { "id": "userProfile_v1", "document": "query UserProfile($id: Int!) { user(id: $id) { id name email } }" } }The management mutations and queries always bypass enforcement, because persisting them by id would be a chicken-and-egg. The carve-out is decided from the document's structure, not its text: it applies only when every operation in the request selects operations at the root and nothing but persistedOperations beneath it. A document that mixes the management surface with any other field, including another operations namespace such as deadLetters, does not qualify and is enforced normally, as is one that does not parse. An alias or a string argument that happens to read persistedOperations is neither the field being selected nor part of the document's structure, so it does not qualify either.
The carve-out is not an authorization boundary. Enforcement is a request-shaping control; what protects the management surface is the operations namespace's authorization posture: GateOperations(...), the builder's RequireAuthorization(), or an explicit AllowAnonymousOperations(). A host that exposes the namespace with none of the three refuses to start.
Programmatically
IPersistedOperationsService is the in-process management surface, registered by both UsePersistedOperations and AddPersistedOperationStore. It takes the same inputs and returns the same payloads as the GraphQL fields, and never throws for a refused change:
var service = serviceProvider.GetRequiredService<IPersistedOperationsService>();
var result = await service.UploadAsync(
new UploadPersistedOperationInput("userProfile_v1", "query UserProfile($id: Int!) { user(id: $id) { id name email } }"),
cancellationToken
);
if (!result.Success)
foreach (var error in result.Errors)
Console.WriteLine($"{error.Code}: {error.Message}");IPersistedOperationStore is the lower-level store underneath it, which throws the structured exceptions below. Useful for tests or one-off scripts:
var store = serviceProvider.GetRequiredService<IPersistedOperationStore>();
await store.UpsertAsync(
"userProfile_v1",
"query UserProfile($id: Int!) { user(id: $id) { id name email } }",
options: null,
cancellationToken
);Every upsert / deactivate / restore writes a row to trax.persisted_operation_history for audit and rollback.
Validation
Every upsert runs the candidate document through HotChocolate's standard validation rules against the live schema before any row is written. The same rules HotChocolate runs at request time, so anything that passes here will execute at runtime (modulo runtime data shape).
IPersistedOperationStore.UpsertAsync throws one of four structured exceptions on failure:
| Exception | When |
|---|---|
PersistedOperationParseException | Syntax error. Carries Line, Column, OriginalMessage. |
PersistedOperationValidationException | Schema mismatch (unknown field, wrong variable type, etc.). Carries IReadOnlyList<ValidationFailure>. |
ShapeDiffViolationException | Edit changes the response shape of an existing id. Carries OldFingerprint, NewFingerprint. |
PersistedOperationInputException | The document holds no operation, or more than one. Code INVALID_INPUT. |
All four inherit PersistedOperationException. The GraphQL mutations and the dashboard editor project them into structured error payloads with the codes documented above.
Hosts using AddPersistedOperationStore(...) (admin tooling without a HotChocolate schema in process) get a no-op validator instead. The shape-diff guardrail still runs. AddPersistedOperationStore is complete on its own: the store resolves from a container with nothing else from this package, and with no request executor there is no HotChocolate cache to empty after a write.
Coexistence with @authorize
The HotChocolate-backed validator runs the same rules HotChocolate runs at request time, including the authorize-rule aggregator that fires whenever services.AddAuthorization() has been wired into the GraphQL builder (which Trax does automatically as soon as any [TraxQueryModel] carries [TraxAuthorize]). The validator resolves IAuthorizationHandler from the host service provider and seeds it into the validation context so the rule passes; no upload-time auth check actually fires (Trax uses ApplyPolicy.BeforeResolver, so the authorize directive is invoked at execution time only). Hosts that have not wired authorization at all keep using the validator as before.
Shape-diff guardrail
Every UpsertAsync computes a canonicalized structural hash (sha-256) of the response shape using the document AST. The fingerprint is stored in the row alongside the document. On an edit of an existing id, the store compares the old and new fingerprints and refuses a change of response shape with ShapeDiffViolationException (SHAPE_DIFF_VIOLATION) unless the caller sets UpsertOptions.BypassShapeDiff (bypassShapeDiff on the upload mutation, the Bypass shape-diff guardrail checkbox in the dashboard editor). The check runs in the store, so every path that writes goes through it.
The fingerprint considers these the same shape: whitespace, field reordering, argument changes, variable additions, type-extension swaps that preserve fields. It treats these as different: adding/removing/renaming a field, alias changes, fragment-spread vs inlined fields, @include / @skip directive changes, mutation vs query.
What lives where
| Component | Schema | Purpose |
|---|---|---|
trax.persisted_operation | Postgres trax | Live id -> document mapping |
trax.persisted_operation_history | Postgres trax | Append-only audit of every change |
IPersistedOperationsService | DI | Management surface shared by the GraphQL fields and the dashboard |
IPersistedOperationStore | DI | Programmatic CRUD |
IPersistedOperationValidator | DI | Schema validation at upsert time (HotChocolate-backed when UsePersistedOperations, no-op for AddPersistedOperationStore) |
IPersistedOperationsCapability | DI | Marker registered by UsePersistedOperations, meaning this process serves the management GraphQL fields. The dashboard does not read it: its pages appear whenever IPersistedOperationsService is registered |
IOperationDocumentStorage | HotChocolate hot path | Resolves id to document for the request executor |
PersistedOperationEnforcementMiddleware | HotChocolate execution pipeline (after parsing, before validation) | Enforces inline-query rejection / shadow logging / allowlist on every transport, HTTP and WebSocket alike |
IPersistedOperationBroadcaster | DI | Multi-node cache invalidation (RabbitMQ with UseRabbitMqInvalidation, no-op with SingleNode()) |
SDK Reference
UsePersistedOperations | UsePersistedOperationsEnforcement | PersistedOperationsBuilder | IPersistedOperationsService | IPersistedOperationStore | IPersistedOperationValidator | PersistedOperationExceptions | Management mutations | PersistedOperation | ShapeFingerprintComputer