Skip to main content
Veriqa has two independent stores:
  • Transaction store — the state of sign-in / confirmation transactions (Transaction Engine).
  • OpenIddict database — the OIDC server’s clients and tokens.
A third one is optional and off by default: the collected claims store, which remembers the claims Veriqa collected during a sign-in.

Transaction store

Configured in ConfigureTransactionEngine. Pick one store.
Program.cs
UseEfCoreStore itself ships in a satellite package — the core does not drag EF Core in transitively. The EF Core provider (UseNpgsql, UseSqlServer) and its NuGet package belong to the host application — the core is provider-agnostic. The migrations assembly named in MigrationsAssembly(...) ships as one more package — without it the schema is never created:
Migrations ship as one package per store and provider; the package name is also the value for MigrationsAssembly(...): The audit trail keeps its migration history in a table of its own, so that it can share a database with the transaction store: next to MigrationsAssembly(...) in UseEfCoreSink, pass MigrationsHistoryTable(AuditMigrationDefaults.MigrationsHistoryTable). Redis parameters (RedisTransactionStoreOptions):
  • Configuration — the connection string (for example, localhost:6379).
  • KeyPrefix — the transaction key prefix (default veriqa:tx:).
On the standalone image the store is chosen by keys, not code: Veriqa:TransactionEngine:Store:Provider is PostgreSQL, SqlServer or Redis, and Veriqa:TransactionEngine:Store:ConnectionString holds the connection string of that store. On Redis it goes into Configuration, and Veriqa:TransactionEngine:Store:KeyPrefix sets KeyPrefix. Without a connection string the store stays in memory whatever the provider says. A Redis store lends nothing to the audit sink (see Audit sink) and cannot hold the collected claims store; readiness gets a redis-transactions check, and a Redis unreachable at startup stops the container (unless the connection string sets abortConnect=false: then the container starts and only readiness reports the outage).

A new sign-in closes the earlier ones

When a user starts a sign-in again and reaches the channel with the same account, the earlier sign-ins of that account to the same application that are still waiting for an answer are closed — see Starting the sign-in again. The transaction store is what finds them, so the upgrade touches each store in its own way:
  • InMemory — nothing to do.
  • EF Core — the migration AddSignInSubjectFingerprint adds one nullable column with a non-unique index to the transactions table. It ships in both migration packages and is applied like any other migration. Sign-ins written before it carry no fingerprint: they are not closed by a newer one and live until they expire.
  • Redis — next to the transactions, under the same KeyPrefix, the store keeps one set of transaction identifiers per tenant, application and channel account. The key holds a hash, never the user’s identifier in the channel, and the set expires on its own.
  • A store of your own — ITransactionStore has a new member, GetPendingSignInsAsync, and it has no default implementation: your store stops compiling until it implements it. The member’s obligations are described in the README of the Veriqa.Core.TransactionEngine package, section “Obligations of GetPendingSignInsAsync”.

Channel stores: multiple replicas

The channel layer keeps several stores of its own. By default they are in-process — enough for a single replica, but not for two: If you run more than one replica, wire the Redis implementations of all these stores with a single call on the channel adapter builder:
Program.cs
Call order does not matter: the satellite registers the stores unconditionally and wins over the in-process defaults either way. Without a UseRedisChannelStores call everything stays in-process — the default implementations are never replaced behind your back. Parameters (RedisChannelStoreOptions):
  • Configuration — the connection string. Leave it empty to deliberately reuse an IConnectionMultiplexer registered in the container by someone else. Setting it while such a registration exists is refused: the host stops at startup with a configuration error naming both connections by their endpoints, logical database, TLS settings and sentinel master name (credentials are never printed) — otherwise the configured string would be silently ignored and the channel stores would land in someone else’s Redis. The check runs when the host starts, so the order of the two registrations does not matter.
  • PromptKeyPrefix — prompt coordinate key prefix (default veriqa:ch:prompt:).
  • EmailActionTokenKeyPrefix — action token key prefix (default veriqa:ch:email:token:).
  • EmailPushCorrelationKeyPrefix — push correlation key prefix (default veriqa:ch:email:corr:).
  • ProcessedMessageKeyPrefix — processed-message key prefix (default veriqa:ch:email:msg:).
  • EmailClaimCompletionKeyPrefix — key prefix of the email completion store (default veriqa:ch:email:claim:). Addresses and channel accounts in its keys are SHA-256 fingerprints, and codes and link tokens are kept only as fingerprints.
  • PhoneRequestCorrelationKeyPrefix — phone number request key prefix (default veriqa:ch:phone:). The rest of the key is a SHA-256 hash of the chat, so the channel user identifier is not stored in plain text.
  • LinkHandleKeyPrefix — link handle key prefix (default veriqa:ch:link:). The rest of the key is a SHA-256 fingerprint of the handle; the handle itself is never stored.
  • StartCodeKeyPrefix — key prefix of the short codes a sign-in is started by (default veriqa:ch:code:). The rest of the key is a SHA-256 hash; the code itself is never part of a key.
  • ProcessedMessageRetention — how long a “message processed” record is kept (default 24 hours).
Record lifetimes come from the entities themselves: a token and a correlation live exactly until their own ExpiresAt, and prompt coordinates until the longest possible transaction has expired plus the margin in which the expiry event is published (the coordinates are consumed by that event, which the background cleanup sweep publishes once the transaction has expired, unless an answer that came after the deadline has already published it). The single exception is ProcessedMessageRetention — a processed-message record has no owning entity.
Validate the Redis channel stores in your host once you turn them on. There is no fallback to the in-process stores: an unreachable Redis fails the operation instead of silently falling back to stores that break on a second replica.
On the standalone image the stores are switched on by one key, Veriqa:ChannelStores:Redis:ConnectionString; without it they stay in process. The parameters above are keys of the same section under the same names — Veriqa:ChannelStores:Redis:LinkHandleKeyPrefix, Veriqa:ChannelStores:Redis:ProcessedMessageRetention (hh:mm:ss) and so on; the connection string is ConnectionString, not Configuration. With the transaction store on Redis both share a single connection: set the same connection string in both keys. A different one stops startup with a message naming the two keys. Readiness gets a redis-channel-stores check unless the same Redis is already probed by another check, and a Redis unreachable at startup stops the container (unless the connection string sets abortConnect=false: then the container starts and only readiness reports the outage).

Channel identities: nothing is stored out of the box

The IChannelIdentityRepository port (transaction engine) persists a channel identity — channel user id, phone, email, display name — as an upsert keyed by the triple (TenantId, ChannelType, ChannelUserId). The contract provides neither deletion nor TTL: a record is kept indefinitely. Veriqa ships no implementation of this port and registers none. Without a registration of your own nothing is stored, and the sign-in still works: the resolved identity snapshot travels with the transaction. Keeping that data (PII, kept indefinitely) is your decision — register an implementation and every sign-in becomes an upsert:
Program.cs
The lifetime is yours to choose: the resolver asks for the port once per operation from a scope of its own, so a Scoped implementation over a DbContext is as safe as a Singleton one. A reference in-memory implementation lives in the samples/dotnet/inproc/login sample. The full picture of what is kept where and for how long — this port included — is in Security and data handling.

Collected claims store

When a sign-in lacks a claim the application requires, Veriqa collects it itself — a phone number requested in the channel, an email confirmed with a code or a link, a field of its form (see Claim enrichment). Without a store all of that lives only as long as the transaction, and the next sign-in asks again. The collected claims store keeps it keyed by the tenant and the sub: what Veriqa collected, the Optional claims the user declined, the time of the last sign-in that read the records and the sub an application received when its claims mapper changed it. Values the channel itself supplies are not stored. Nothing is stored until you turn it on. On the standalone server that is one key, Veriqa:CollectedClaims:Store:Enabled:
appsettings.json
The store then lives in the transaction store’s database — the same provider, connection string and migrations assembly — with a migration history table of its own. Enabled: true without Veriqa:TransactionEngine:Store:ConnectionString stops startup with a message naming both keys, and so does Enabled: true with Veriqa:TransactionEngine:Store:Provider = Redis: Redis has no relational database to keep the store in. In the embedded mode the store is a registration of its own, and its database is yours to choose:
Program.cs
The migrations ship in the transaction store’s migration packages as a second context, CollectedClaimsDbContext, and are applied at startup like the transaction store’s. Keep MigrationsHistoryTable(CollectedClaimsMigrationDefaults.MigrationsHistoryTable) — the store may share a database with the transactions, and a shared history table would mix the two sets of migrations. Your own implementation of ICollectedClaimsStore is plugged in with te.UseCollectedClaimsStore<TStore>() and gets everything below — the endpoint, the host call and the retention sweep — exactly as the EF Core one does.
The store is one per tenant, shared by all its applications: a value collected for one application is issued to another of the same tenant when it passes that application’s rules, and a deletion made at the request of one application erases the records for all of them.

Deleting by sub

When a user deletes their account or asks for their data to be erased, you, as the data controller, delete everything the store keeps about them — all their records and the subjects issued to applications. One operation has two entry points; both are idempotent, so a sub with nothing stored is a success. The server endpoint — for an application that does not embed Veriqa as a .NET library:
The route exists only when a collected claims store is registered. The caller is a server client with AllowClientCredentials and AllowCollectedClaimsDeletion (see Clients; the flag without AllowClientCredentials stops startup), and its token comes from grant_type=client_credentials — as for the confirmation API. sub is passed in the form the application received it: the form of the application that client_id names, or of the calling client when the parameter is absent. The subjects issued to that application are searched within the caller’s tenant; a sub not found among them is taken as the core one. The endpoint has no rate limit of its own — grant the flag to one of your own server clients only. The host call — for an application that embeds Veriqa:
ICollectedClaimsDeletion is registered together with the store; without a store it is not in the container. Each deletion writes a CollectedClaimsDeleted record to the audit trail when the trail is on: the actor is the calling client for the endpoint and system for the host call and the sweep; the target is the masked core sub; the metadata carry the tenant, the client and the basis — endpoint, host_call or retention. Claim values never reach the record. With the trail off the deletion works and writes nothing. After a deletion the next sign-in of that user works like the first one: Veriqa asks again for what is missing, a previously declined Optional claim included.

Deleting unused records

Records of a sub nobody has signed in with for a given number of days can be deleted automatically. The clock starts at the last sign-in that read the records, not at the time a value was collected. Off by default:
appsettings.json
0 turns it off; a negative value stops startup. At the tenant level the member ClaimCompletionCollectedClaimsRetentionDays replaces the global value, so the sweep can run for one tenant only (ClaimCompletion.CollectedClaimsRetentionDays in the declaration catalog). The sweep runs at startup and then hourly, in batches of 100; a sign-in that reads the records between the selection and the deletion keeps them. Each deleted sub gets an audit record with the basis retention.

OpenIddict database

The OIDC server’s clients and tokens are stored separately and configured with the Veriqa:OpenIddict:Database section:
appsettings.json
This choice has no default. An empty section is not “InMemory by default”, it is a choice nobody made: outside the Development environment the application fails to start (the startup check refuses and names the fix); in Development it starts with a Warning. In the embedded mode the section does not select the provider — there the choice is made in the host’s code: authServer.UseOpenIddictDatabase(ef => ef.UseNpgsql(connectionString)) for a relational provider, or authServer.UseInMemoryOpenIddictStore() for the volatile one.
In production, initialize the relational DB with migrations, not schema auto-creation — that keeps a schema history and predictable upgrades (see Preparing for production).

Single-instance connector without a database

When Veriqa is attached to another identity provider — Keycloak or your own — it takes part in the sign-in and in nothing after it: the authorization request, the confirmation in the channel, the code exchange and the userinfo call right after it. The session and its refresh tokens then belong to that provider, and Veriqa keeps no user accounts either (see Channel identities). Such a deployment can run as a single instance with every store in memory. In self-hosted mode:
appsettings.json
  • leave Veriqa:TransactionEngine:Store:ConnectionString unset — the transactions stay in memory;
  • leave Veriqa:Logging:Mode at its default, or at anything other than Audit;
  • declare the client without AllowRefreshTokens (off by default) and without offline_access.
In the embedded mode the same choice is authServer.UseInMemoryOpenIddictStore() plus te.UseInMemoryStore(). The host starts with a Warning that the OpenIddict store is volatile — for this setup that is expected. What a restart costs: A deploy, an image update and a container restart are all restarts: each of them has every effect listed in the table above.
Pick a database instead when:
  • Veriqa is the only identity provider of an application that keeps sessions with refresh tokens (AllowRefreshTokens) — its users sign in again after every restart;
  • you run more than one instance — each one has stores of its own (see Running more than one instance);
  • Veriqa:Logging:Mode is Audit — an in-memory trail keeps the host from starting outside Development (see Audit sink).

Another database or a model change

The standalone image ships PostgreSQL and SQL Server (and Redis for the transaction store). Anything else is a choice your own host makes in the embedded mode — no change to Veriqa is needed.

Your own migrations assembly

The four EF Core contexts are public: TransactionDbContext (Veriqa.Core.TransactionEngine.Store.EfCore), CollectedClaimsDbContext (Veriqa.Core.TransactionEngine.CollectedClaims.EfCore), AuditDbContext (Veriqa.Core.AuditTrail.Store.EfCore) and OpenIddictDbContext (Veriqa.Core.AuthServer.Data). They are anchors for migrations, not a data access API — their tables stay internal. For a database Veriqa ships no migrations for:
  1. Create a class library that references your EF Core provider, Microsoft.EntityFrameworkCore.Design and the package holding the context.
  2. Add an IDesignTimeDbContextFactory<T> for each context — dotnet ef builds the context through it.
  3. Run dotnet ef migrations add Initial --project <your library> --context <context>, and name the library in MigrationsAssembly(...) at runtime.
TransactionDbContextDesignTimeFactory.cs
For AuditDbContext, add MigrationsHistoryTable(AuditMigrationDefaults.MigrationsHistoryTable) next to MigrationsAssembly(...) — in the factory and at runtime alike; for CollectedClaimsDbContext, MigrationsHistoryTable(CollectedClaimsMigrationDefaults.MigrationsHistoryTable) in the same way.

Changing the model

A schema, a collation, a column type — change the model with the standard EF Core IModelCustomizer, not by editing Veriqa’s contexts. Derive from RelationalModelCustomizer:
VeriqaModelCustomizer.cs
Plug it in with ReplaceService<IModelCustomizer, …>() inside the same delegate that picks the provider:
Program.cs
For the OpenIddict database, Veriqa applies its own OpenIddict setup before calling your delegate, so the replacement you make there is the one that stays. A changed model no longer matches the shipped migrations, even on PostgreSQL or SQL Server — generate your own assembly for it as described above.
The design-time factory must make the same replacement. dotnet ef builds the model through the factory, the application through your delegate. If ReplaceService<IModelCustomizer, …>() is missing on either side, the migration describes one model and the application runs another.
Keep the base.Customize call. It is what runs the context’s OnModelCreating. Without it the model has neither the OpenIddict entities nor Veriqa’s own configuration of its tables.

Publishing transaction events

In-process delivery to ITransactionEventHandler (see Configuration) is part of the engine and needs no call of its own: the audit trail, the real-time sign-in push and the prompt cleanup receive events in every deployment. Publishing events out of the process is a separate, additive choice of a transport — also made in ConfigureTransactionEngine:
Program.cs
RabbitMqEventPublisherOptions: ConnectionString (required, amqp://…), ExchangeName (default veriqa.transactions), plus queue and reconnection parameters (QueueCapacity, MaxReconnectAttempts, ReconnectBaseDelaySeconds). On the standalone image the publisher is switched on by Veriqa:Events:RabbitMq:ConnectionString; the other parameters are keys of the same section under the same names (Veriqa:Events:RabbitMq:ExchangeName and so on). Without the connection string there is no publisher. With it, readiness gets a rabbitmq-events check, and a broker unreachable at startup stops the container. Reliability of what leaves the process is the reliability of the chosen bus — no more and no less. Veriqa builds no delivery guarantee of its own on top of the transport: with the in-process channel an event can be lost on a crash, and with a message queue delivery is exactly as reliable as that queue makes it.

Next steps

Preparing for production

Migrations, secrets, key rotation.

Configuration & customization

Channels, design, localization, extension points.