Skip to main content
Version: v0.25.0 (Latest)

Verifier DCQL Bindings

A tenant typically defines its DCQL queries once at the tenant level. The same age_verification query lives in the versioned DCQL store and gets edited over time. But a tenant also typically hosts multiple verifiers (a checkout flow, a customer-portal flow, a third-party-relying-party integration), each of which may want to use the shared query at a specific version. The VDX VerifierDcqlBinding is the thin layer that captures that pin: which verifier uses which shared DCQL query, at which version, with which local alias and enabled flag.

This is a VDX-level concern, not a base EDK one. The IDK and the bare EDK ship a tenant-level versioned DCQL resolver; this module adds the per-verifier binding store, the verifier-aware resolver that replaces the tenant-level one, the REST surface for managing bindings, and the scheduled-activation projection over the EDK scheduler.

The Binding Model

data class VerifierDcqlBinding(
val id: String,
val tenantId: String,
val verifierId: String,
val dcqlQueryId: String,
val pinnedVersion: Int,
val enabled: Boolean = true,
val alias: String? = null,
val createdAt: Instant,
val updatedAt: Instant,
)

A binding is identified by (tenantId, verifierId, dcqlQueryId) and points at a specific pinnedVersion of the shared query. The binding never copies the DCQL body or its history; that lives in the versioned DCQL store. Two verifiers binding the same dcqlQueryId at different versions consume two pointers into the same history, not two copies of the body.

enabled lets an operator disable a binding without deleting it; a disabled binding is rejected at authorization-request creation time with an ILLEGAL_ARGUMENT_ERROR. alias is a verifier-local short name for the query, useful when the same shared query is used in a UI that needs to label it per verifier (for example, "Age check (checkout)" vs "Age check (customer portal)" backed by the same age_verification query).

Bindings are soft-deleted (a deletedAt timestamp); live reads exclude soft-deleted rows.

How Resolution Uses the Binding

When the universal OID4VP backend creates an authorization request for a query_id, it calls DcqlQueryResolver.resolveForCreate(queryId, verifierId). When the VDX binding module is on the classpath, the resolver is VdxDcqlQueryResolver, which behaves as follows:

If verifierId is null, fall back to current-version resolution on the versioned store. This matches the EDK base behaviour and is what the Universal API uses when no per-verifier context is in scope.

If verifierId is not null, look up the live binding for (tenantId, verifierId, queryId). Reject with NOT_FOUND_ERROR if no binding exists. Reject with ILLEGAL_ARGUMENT_ERROR if the binding is disabled. Otherwise read the body for binding.pinnedVersion from the versioned store and snapshot (queryId, pinnedVersion) into the authorization session. The wallet's view of the request is therefore frozen at the pinned version regardless of what subsequent DCQL edits do.

The pinned version is also returned in ResolvedDcqlQuery.version, so the audit log and the authorization-session record both capture it.

REST API

The binding surface is exposed by two HTTP adapters, both mounted under the DCQL admin base path /api/dcql/v1. The verifier-scoped adapter (/api/dcql/v1/verifiers/{verifierId}/bindings) is what a verifier admin uses to manage that verifier's bindings and their scheduled activations. The reverse-lookup adapter (/api/dcql/v1/queries/{queryId}/verifiers) is what a tenant admin uses to find every verifier bound to a shared query.

Every endpoint requires an OIDC bearer token; the tenant is resolved from the validated JWT, never from a client-supplied header. Each REST endpoint delegates to a ServiceCommand (the oid4vp.dcql.* ids below), so the same operation is reachable in-process through the command invoker and over the internal transport.

Verifier-Scoped Bindings

Base path: /api/dcql/v1/verifiers/{verifierId}/bindings.

Bind a query to a verifier

Pins a shared DCQL query to the verifier. Returns 201 Created with the resulting VerifierDcqlBinding. A 400 is returned for a malformed body and a 404 if the verifier or query does not exist.

Supply the queryId to bind; the verifierId comes from the path. version is optional — when omitted the binding pins the DCQL query's current version at the moment of the call. The result is the stored VerifierDcqlBinding.

POST /api/dcql/v1/verifiers/{verifierId}/bindings

The full request and response schema is in the API reference, or open the API reference tab to read it inline.

Read and list bindings

Lists every binding held by the verifier named in the path.

GET /api/dcql/v1/verifiers/{verifierId}/bindings

The full request and response schema is in the API reference, or open the API reference tab to read it inline.

Reads the single binding for the (verifierId, queryId) pair taken from the path. 404 if no such binding exists.

GET /api/dcql/v1/verifiers/{verifierId}/bindings/{queryId}

The full request and response schema is in the API reference, or open the API reference tab to read it inline.

Update a binding

Immediately advances or rolls back the pinned version, toggles enabled, or sets the alias. A null (or omitted) field leaves the stored value unchanged. For a change that should land at a future time, schedule an activation instead. Returns 200 OK with the updated binding.

Supply only the fields to change; the verifierId and queryId come from the path. A pinnedVersion update takes effect immediately.

PATCH /api/dcql/v1/verifiers/{verifierId}/bindings/{queryId}

The full request and response schema is in the API reference, or open the API reference tab to read it inline.

Unbind

Removes the binding for (verifierId, queryId). Returns 200 OK with the VerifierDcqlBinding that was removed, so the caller gets the resource it acted on rather than a bare flag; a missing binding is 404.

DELETE /api/dcql/v1/verifiers/{verifierId}/bindings/{queryId}

The full request and response schema is in the API reference, or open the API reference tab to read it inline.

Scheduled Activations

A scheduled activation moves the binding's pinned version on a future date. It is projected over the EDK scheduler's scheduled_command table, not a dedicated activations table. When the scheduler fires at effectiveAt, it runs an internal command that calls the binding update path; from the binding's perspective this is identical to an operator calling the update endpoint with pinnedVersion set to the target. The activation appears in the binding's history through the audit trail.

Supply the effectiveAt timestamp and an optional pinnedVersion (defaults to the query's current version when omitted). The verifierId and queryId come from the path. Returns 201 Created with the resulting VerifierDcqlScheduledActivation in PENDING status.

POST /api/dcql/v1/verifiers/{verifierId}/bindings/{queryId}/activations

The full request and response schema is in the API reference, or open the API reference tab to read it inline.

Lists the scheduled activations for the binding named by the path.

GET /api/dcql/v1/verifiers/{verifierId}/bindings/{queryId}/activations

The full request and response schema is in the API reference, or open the API reference tab to read it inline.

Cancels a pending activation by activationId, deleting the corresponding scheduled-command row. Returns 200 OK with the cancelled VerifierDcqlScheduledActivation (its status is now CANCELLED). Activations that have already executed cannot be cancelled (they appear with APPLIED status and are not part of any cancellable set).

DELETE /api/dcql/v1/verifiers/{verifierId}/bindings/{queryId}/activations/{activationId}

The full request and response schema is in the API reference, or open the API reference tab to read it inline.

The activation's status is projected from the underlying scheduled-command lifecycle:

StatusMeaning
PENDINGScheduled, not yet executed
APPLIEDExecuted successfully; the binding's pinnedVersion was updated
CANCELLEDThe activation was cancelled before its effectiveAt
FAILEDExecution threw; the binding's pinnedVersion is unchanged and the error is in the audit log

Reverse Lookup

When you need to find every verifier in a tenant that is currently bound to a shared DCQL query (for example, before publishing a new version, to know who is going to be affected):

Lists every binding that references the queryId from the path — one VerifierDcqlBinding per bound verifier, each carrying the version it pins. Returns an empty array in single-verifier deployments.

GET /api/dcql/v1/queries/{queryId}/verifiers

The full request and response schema is in the API reference, or open the API reference tab to read it inline.

Why Two Bind Paths

The binding system shows up in two places for a reason. The verifier-scoped surface (/api/dcql/v1/verifiers/{verifierId}/bindings) is what a verifier admin uses: they manage their verifier's bindings. The reverse-lookup surface (/api/dcql/v1/queries/{queryId}/verifiers) is what a tenant admin uses when they own the shared DCQL queries and want to understand or plan an organisation-wide change. The same data is exposed from both angles; the access policy on each surface is configured independently through the EDK authorization layer.

Persistence

Two backends are shipped, paralleling the DCQL store itself:

ModuleBackend
lib-data-store-oid4vp-dcql-binding-persistence-postgresPostgreSQL via SQLDelight
lib-data-store-oid4vp-dcql-binding-persistence-mysqlMySQL via SQLDelight

Both implement the VerifierDcqlBindingRepository interface from the public module. The schema is small (one verifier_dcql_binding table keyed by (tenant_id, verifier_id, dcql_query_id) with the mutable fields plus deleted_at); no shared infrastructure table is needed.

The scheduled-activations projection has no table of its own; it queries the EDK scheduler's scheduled_command rows whose target command id and arguments encode "advance binding X to version Y" and groups them by (verifierId, dcqlQueryId).

Wiring

Include the binding module alongside the versioned DCQL store. The VdxDcqlQueryResolver is bound with replaces = [EdkDcqlQueryResolver::class], so the verifier-aware resolver automatically takes the place of the tenant-only resolver in any assembly that has both on the classpath. Code that calls DcqlQueryResolver.resolveForCreate(...) keeps the same signature.