diff --git a/vcr/dcql/README.md b/vcr/dcql/README.md
new file mode 100644
index 0000000000..70c52ed47e
--- /dev/null
+++ b/vcr/dcql/README.md
@@ -0,0 +1,158 @@
+# DCQL — Digital Credentials Query Language
+
+This package implements a subset of the Digital Credentials Query Language (DCQL)
+as specified in [OpenID for Verifiable Presentations 1.0](https://openid.net/specs/openid-4-verifiable-presentations-1_0.html),
+sections 6.1, 6.3, and 7.
+
+## Purpose
+
+DCQL is used in this codebase for **deterministic credential selection** from the wallet.
+When multiple credentials of the same type exist (e.g., multiple PatientEnrollmentCredentials
+for different patients), the EHR provides a DCQL credential query to specify which one to present.
+
+This differs from the spec's primary use case, where DCQL is used by a Verifier to request
+selective disclosure from a Wallet. In that context, the `values` parameter is a best-effort
+privacy hint — the spec states: *"Verifiers MUST treat restrictions expressed using values as
+a best-effort way to improve user privacy, but MUST NOT rely on it for security checks."*
+
+In our context, the node is selecting from its own wallet on behalf of the EHR. The matching
+is deterministic: if a credential matches the query, it is selected. If no credential matches,
+an empty result is returned. The caller (e.g., the `CredentialSelector` in the PD matcher) is
+responsible for deciding whether an empty result is an error. There is no privacy negotiation
+involved.
+
+## Supported subset
+
+### Credential Query (section 6.1)
+
+| Field | Status | Notes |
+|-------|--------|-------|
+| `id` | Supported | Validated: non-empty, alphanumeric/underscore/hyphen |
+| `claims` | Supported | Array of claims queries |
+| `format` | Not supported | Format selection handled by Presentation Definition matching |
+| `meta` | Not supported | Metadata constraints handled by Presentation Definition matching |
+| `multiple` | Not supported | Handled by `match_policy` in the filter chain |
+| `claim_sets` | Not supported | Not needed for value-based selection |
+| `trusted_authorities` | Not supported | Trust handled by PD matching and DID resolution |
+| `require_cryptographic_holder_binding` | Not supported | Handled by VP verification |
+
+### Claims Query (section 6.3)
+
+| Field | Status | Notes |
+|-------|--------|-------|
+| `path` | Supported | Claims Path Pointer per section 7 |
+| `values` | Supported | Exact value matching with OR semantics |
+| `id` | Not supported | Only needed with `claim_sets` |
+
+### Claims Path Pointer (section 7)
+
+| Element type | Status | Notes |
+|-------------|--------|-------|
+| String | Supported | Key lookup in JSON objects |
+| Non-negative integer | Supported | Array index lookup |
+| Null | Supported | Wildcard — selects all array elements |
+
+The path starts at the credential root, supporting top-level fields (`issuer`, `type`, etc.)
+as well as nested `credentialSubject` fields.
+
+`credentialSubject` handling: the W3C VC data model allows `credentialSubject` to be either a
+single object or an array. The Go VC struct always models it as `[]map[string]any`. The DCQL
+spec examples use paths without an array index (e.g., `["credentialSubject", "family_name"]`).
+
+- **Single credentialSubject** (common case): the array is automatically unwrapped to a single
+ object, so paths like `["credentialSubject", "patientId"]` work without an index.
+- **Multiple credentialSubjects** (rare): the path must include an explicit integer index to
+ select which subject, e.g., `["credentialSubject", 0, "patientId"]`. Using a string key on
+ an array with multiple elements returns an error.
+
+## Examples
+
+### Select a PatientEnrollmentCredential by BSN
+
+```json
+{
+ "id": "id_patient_enrollment",
+ "claims": [
+ {
+ "path": ["credentialSubject", "hasEnrollment", "patient", "identifier", "value"],
+ "values": ["123456789"]
+ }
+ ]
+}
+```
+
+### Select a credential by multiple possible values (OR)
+
+```json
+{
+ "id": "id_patient_enrollment",
+ "claims": [
+ {
+ "path": ["credentialSubject", "hasEnrollment", "patient", "identifier", "value"],
+ "values": ["123456789", "987654321"]
+ }
+ ]
+}
+```
+
+### Select by issuer DID
+
+```json
+{
+ "id": "id_provider",
+ "claims": [
+ {
+ "path": ["issuer"],
+ "values": ["did:x509:0:sha256:abc123::san:otherName:12345678"]
+ }
+ ]
+}
+```
+
+### Match a value anywhere in an array (null wildcard)
+
+```json
+{
+ "id": "id_delegation",
+ "claims": [
+ {
+ "path": ["credentialSubject", "qualifications", null, "roleCode"],
+ "values": ["30.000"]
+ }
+ ]
+}
+```
+
+This matches if any element in the `qualifications` array has `roleCode` equal to `"30.000"`.
+
+### Multiple claims (AND semantics)
+
+```json
+{
+ "id": "id_enrollment",
+ "claims": [
+ {
+ "path": ["credentialSubject", "hasEnrollment", "patient", "identifier", "value"],
+ "values": ["123456789"]
+ },
+ {
+ "path": ["credentialSubject", "hasEnrollment", "enrolledBy", "identifier", "value"],
+ "values": ["87654321"]
+ }
+ ]
+}
+```
+
+Both claims must match for a credential to be selected.
+
+## Performance
+
+Benchmark on Apple M5, worst case (match last of 2000 credentials, 2 claims with wildcards,
+each credential has multiple identifiers and qualifications):
+
+```
+BenchmarkMatch_2000Credentials ~24ms/op ~24MB/op ~504k allocs/op
+```
+
+The cost is dominated by `json.Marshal`/`json.Unmarshal` per credential for generic root-level
+path resolution. For typical use cases (tens of credentials, not thousands) this is negligible.
diff --git a/vcr/dcql/dcql.go b/vcr/dcql/dcql.go
new file mode 100644
index 0000000000..5b09870fa4
--- /dev/null
+++ b/vcr/dcql/dcql.go
@@ -0,0 +1,317 @@
+/*
+ * Copyright (C) 2026 Nuts community
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+// Package dcql implements a subset of the Digital Credentials Query Language (DCQL)
+// as specified in OpenID4VP sections 6.1, 6.3, and 7.
+//
+// # Supported DCQL features
+//
+// Credential Query (section 6.1):
+// - id: validated per spec (non-empty, alphanumeric/underscore/hyphen)
+// - claims: array of claims queries
+//
+// Claims Query (section 6.3):
+// - path: Claims Path Pointer per section 7 (strings, integers, null)
+// - values: exact value matching with OR semantics
+//
+// Claims Path Pointer (section 7):
+// - String elements: key lookup in JSON objects
+// - Non-negative integer elements: array index lookup
+// - Null elements: wildcard, selects all elements of an array
+// - Path starts at the credential root
+//
+// # Unsupported features
+//
+// The following Credential Query fields are not supported as they are handled
+// by other layers (PD matching, VP verification, filter chain):
+// format, meta, multiple, claim_sets, trusted_authorities, require_cryptographic_holder_binding.
+package dcql
+
+import (
+ "encoding/json"
+ "fmt"
+ "math"
+ "regexp"
+
+ "github.com/nuts-foundation/go-did/vc"
+)
+
+var validIDPattern = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
+
+// CredentialQuery represents a DCQL credential query as defined in OpenID4VP section 6.1.
+type CredentialQuery struct {
+ // ID is an identifier for the credential query. When used with PD matching,
+ // this maps to a PD input descriptor ID.
+ ID string `json:"id"`
+ // Claims specifies the claim requirements for matching credentials.
+ Claims []ClaimsQuery `json:"claims"`
+}
+
+// ClaimsQuery represents a DCQL claims query as defined in OpenID4VP section 6.3.
+type ClaimsQuery struct {
+ // Path is a Claims Path Pointer (OpenID4VP section 7) specifying the path to a claim
+ // within the credential. Elements can be strings (key lookup), non-negative integers
+ // (array index), or nil (array wildcard).
+ Path []any `json:"path"`
+ // Values specifies the expected values of the claim. If present, the credential
+ // matches only if the claim value equals at least one of the values (OR semantics).
+ Values []any `json:"values,omitempty"`
+}
+
+// Match evaluates a DCQL credential query against a list of verifiable credentials
+// and returns the credentials that match all claims in the query.
+// Returns an error if the query is invalid (e.g., invalid ID format) or if a credential
+// cannot be processed.
+// Returns an empty slice (not an error) when no credentials match.
+func Match(query CredentialQuery, credentials []vc.VerifiableCredential) ([]vc.VerifiableCredential, error) {
+ if err := validateQuery(query); err != nil {
+ return nil, err
+ }
+ var result []vc.VerifiableCredential
+ for _, cred := range credentials {
+ root, err := credentialToMap(cred)
+ if err != nil {
+ return nil, fmt.Errorf("failed to process credential: %w", err)
+ }
+ matched, err := matchesAll(query.Claims, root)
+ if err != nil {
+ return nil, err
+ }
+ if matched {
+ result = append(result, cred)
+ }
+ }
+ return result, nil
+}
+
+func validateQuery(query CredentialQuery) error {
+ if !validIDPattern.MatchString(query.ID) {
+ return fmt.Errorf("invalid credential query id: must be a non-empty string consisting of alphanumeric, underscore, or hyphen characters")
+ }
+ for i, claim := range query.Claims {
+ if len(claim.Path) == 0 {
+ return fmt.Errorf("claims[%d]: path must be a non-empty array", i)
+ }
+ }
+ return nil
+}
+
+// credentialToMap converts a credential to a generic JSON map for path resolution.
+func credentialToMap(cred vc.VerifiableCredential) (map[string]any, error) {
+ data, err := json.Marshal(cred)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal credential: %w", err)
+ }
+ var root map[string]any
+ if err := json.Unmarshal(data, &root); err != nil {
+ return nil, fmt.Errorf("failed to unmarshal credential: %w", err)
+ }
+ return root, nil
+}
+
+// normalizeCredentialSubjectPath adjusts a claims path for the credentialSubject field.
+// The Go VC struct models credentialSubject as []map[string]any. When marshalled to JSON:
+// - Single element: serialized as a plain object (not an array)
+// - Multiple elements: serialized as an array
+//
+// This function ensures the path works regardless of how credentialSubject was serialized:
+// - Path without index on a single object: works as-is (no change needed)
+// - Path with index 0 on a single object: strips the index so the path resolves against the object
+// - Path without index on an array: inserts index 0 for single-element arrays
+// - Path with index on an array: works as-is (no change needed)
+func normalizeCredentialSubjectPath(path []any, root map[string]any) []any {
+ if len(path) < 2 {
+ return path
+ }
+ key, ok := path[0].(string)
+ if !ok || key != "credentialSubject" {
+ return path
+ }
+ cs, ok := root["credentialSubject"]
+ if !ok {
+ return path
+ }
+
+ hasIndex := false
+ switch path[1].(type) {
+ case int, float64:
+ hasIndex = true
+ }
+
+ switch cs.(type) {
+ case map[string]any:
+ // Single credentialSubject serialized as object
+ if hasIndex {
+ // Only strip index 0 — higher indices cannot refer to a single object
+ isZero := false
+ switch idx := path[1].(type) {
+ case int:
+ isZero = idx == 0
+ case float64:
+ isZero = idx == 0
+ }
+ if !isZero {
+ return path
+ }
+ // Strip the index — path like ["credentialSubject", 0, "field"]
+ // becomes ["credentialSubject", "field"]
+ normalized := make([]any, 0, len(path)-1)
+ normalized = append(normalized, path[0])
+ normalized = append(normalized, path[2:]...)
+ return normalized
+ }
+ // No index needed, path resolves directly against the object
+ return path
+ case []any:
+ // Multiple credentialSubjects serialized as array
+ arr := cs.([]any)
+ if !hasIndex && len(arr) == 1 {
+ // Insert index 0 for single-element array
+ normalized := make([]any, 0, len(path)+1)
+ normalized = append(normalized, path[0], 0)
+ normalized = append(normalized, path[1:]...)
+ return normalized
+ }
+ // Either already has index, or multiple elements (require index from caller)
+ return path
+ default:
+ return path
+ }
+}
+
+func matchesAll(claims []ClaimsQuery, root map[string]any) (bool, error) {
+ for _, claim := range claims {
+ matched, err := matchesClaim(claim, root)
+ if err != nil {
+ return false, err
+ }
+ if !matched {
+ return false, nil
+ }
+ }
+ return true, nil
+}
+
+func matchesClaim(claim ClaimsQuery, root map[string]any) (bool, error) {
+ path := normalizeCredentialSubjectPath(claim.Path, root)
+ resolved, err := resolveInValue(path, root)
+ if err != nil {
+ return false, err
+ }
+ if resolved == nil {
+ return false, nil
+ }
+ if len(claim.Values) == 0 {
+ return true, nil
+ }
+ return containsExpectedValue(resolved, claim.Values), nil
+}
+
+// containsExpectedValue checks whether the resolved value matches any of the expected values.
+// If the value is a []any (from wildcard path resolution, possibly nested from multiple
+// wildcards), it recursively searches all levels.
+func containsExpectedValue(value any, expectedValues []any) bool {
+ if slice, ok := value.([]any); ok {
+ for _, elem := range slice {
+ if containsExpectedValue(elem, expectedValues) {
+ return true
+ }
+ }
+ return false
+ }
+ for _, expected := range expectedValues {
+ if value == expected {
+ return true
+ }
+ }
+ return false
+}
+
+// resolveInValue resolves a Claims Path Pointer (OpenID4VP section 7) against a JSON value.
+// Path elements can be: string (object key lookup), float64/int (array index), or nil (array wildcard).
+// Returns an error for invalid path elements (non-integer float, unsupported type).
+func resolveInValue(path []any, value any) (any, error) {
+ if len(path) == 0 {
+ return value, nil
+ }
+ switch element := path[0].(type) {
+ case string:
+ m, ok := value.(map[string]any)
+ if !ok {
+ // If the value is an array with >1 elements and the path uses a string key,
+ // the path is ambiguous — it needs an integer index to select an element.
+ if arr, isArr := value.([]any); isArr && len(arr) > 1 {
+ return nil, fmt.Errorf("path uses key '%s' on array with %d elements: use an integer index to select an element", element, len(arr))
+ }
+ return nil, nil
+ }
+ child, ok := m[element]
+ if !ok {
+ return nil, nil
+ }
+ return resolveInValue(path[1:], child)
+ case int:
+ if element < 0 {
+ return nil, fmt.Errorf("invalid path element: %v is not a non-negative integer", element)
+ }
+ return resolveArrayIndex(path[1:], value, element)
+ case float64:
+ // JSON unmarshalling produces float64 for numbers. Validate it represents
+ // a non-negative integer within int range before converting.
+ if element < 0 || math.Trunc(element) != element || element > float64(math.MaxInt) {
+ return nil, fmt.Errorf("invalid path element: %v is not a non-negative integer", element)
+ }
+ return resolveArrayIndex(path[1:], value, int(element))
+ case nil:
+ // Null wildcard: select all elements of the array
+ arr, ok := value.([]any)
+ if !ok {
+ return nil, nil
+ }
+ if len(path) == 1 {
+ return arr, nil
+ }
+ var results []any
+ for _, item := range arr {
+ resolved, err := resolveInValue(path[1:], item)
+ if err != nil {
+ return nil, err
+ }
+ if resolved != nil {
+ results = append(results, resolved)
+ }
+ }
+ if len(results) == 0 {
+ return nil, nil
+ }
+ return results, nil
+ default:
+ return nil, fmt.Errorf("invalid path element type: %T", element)
+ }
+}
+
+func resolveArrayIndex(remainingPath []any, value any, index int) (any, error) {
+ arr, ok := value.([]any)
+ if !ok {
+ return nil, nil
+ }
+ if index < 0 || index >= len(arr) {
+ return nil, nil
+ }
+ return resolveInValue(remainingPath, arr[index])
+}
diff --git a/vcr/dcql/dcql_test.go b/vcr/dcql/dcql_test.go
new file mode 100644
index 0000000000..18217b7572
--- /dev/null
+++ b/vcr/dcql/dcql_test.go
@@ -0,0 +1,915 @@
+/*
+ * Copyright (C) 2026 Nuts community
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+package dcql
+
+import (
+ "encoding/json"
+ "fmt"
+ "testing"
+
+ ssi "github.com/nuts-foundation/go-did"
+ "github.com/nuts-foundation/go-did/vc"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestMatch(t *testing.T) {
+ t.Run("single claim with matching value returns credential", func(t *testing.T) {
+ credential := vc.VerifiableCredential{
+ CredentialSubject: []map[string]any{
+ {"patientId": "123456789"},
+ },
+ }
+ query := CredentialQuery{
+ ID: "test",
+ Claims: []ClaimsQuery{
+ {
+ Path: []any{"credentialSubject", "patientId"},
+ Values: []any{"123456789"},
+ },
+ },
+ }
+
+ result, err := Match(query, []vc.VerifiableCredential{credential})
+
+ require.NoError(t, err)
+ assert.Len(t, result, 1)
+ assert.Equal(t, credential, result[0])
+ })
+ t.Run("single claim with non-matching value returns empty", func(t *testing.T) {
+ credential := vc.VerifiableCredential{
+ CredentialSubject: []map[string]any{
+ {"patientId": "123456789"},
+ },
+ }
+ query := CredentialQuery{
+ ID: "test",
+ Claims: []ClaimsQuery{
+ {
+ Path: []any{"credentialSubject", "patientId"},
+ Values: []any{"999999999"},
+ },
+ },
+ }
+
+ result, err := Match(query, []vc.VerifiableCredential{credential})
+
+ require.NoError(t, err)
+ assert.Empty(t, result)
+ })
+ t.Run("nested path resolves correctly", func(t *testing.T) {
+ credential := vc.VerifiableCredential{
+ CredentialSubject: []map[string]any{
+ {
+ "hasEnrollment": map[string]any{
+ "patient": map[string]any{
+ "identifier": map[string]any{
+ "value": "123456789",
+ },
+ },
+ },
+ },
+ },
+ }
+ query := CredentialQuery{
+ ID: "test",
+ Claims: []ClaimsQuery{
+ {
+ Path: []any{"credentialSubject", "hasEnrollment", "patient", "identifier", "value"},
+ Values: []any{"123456789"},
+ },
+ },
+ }
+
+ result, err := Match(query, []vc.VerifiableCredential{credential})
+
+ require.NoError(t, err)
+ assert.Len(t, result, 1)
+ })
+ t.Run("multiple values use OR semantics", func(t *testing.T) {
+ credential := vc.VerifiableCredential{
+ CredentialSubject: []map[string]any{
+ {"postalCode": "90210"},
+ },
+ }
+ query := CredentialQuery{
+ ID: "test",
+ Claims: []ClaimsQuery{
+ {
+ Path: []any{"credentialSubject", "postalCode"},
+ Values: []any{"90210", "90211"},
+ },
+ },
+ }
+
+ result, err := Match(query, []vc.VerifiableCredential{credential})
+
+ require.NoError(t, err)
+ assert.Len(t, result, 1)
+ })
+ t.Run("multiple values none matching returns empty", func(t *testing.T) {
+ credential := vc.VerifiableCredential{
+ CredentialSubject: []map[string]any{
+ {"postalCode": "12345"},
+ },
+ }
+ query := CredentialQuery{
+ ID: "test",
+ Claims: []ClaimsQuery{
+ {
+ Path: []any{"credentialSubject", "postalCode"},
+ Values: []any{"90210", "90211"},
+ },
+ },
+ }
+
+ result, err := Match(query, []vc.VerifiableCredential{credential})
+
+ require.NoError(t, err)
+ assert.Empty(t, result)
+ })
+ t.Run("multiple claims use AND semantics — all match", func(t *testing.T) {
+ credential := vc.VerifiableCredential{
+ CredentialSubject: []map[string]any{
+ {
+ "patientId": "123",
+ "type": "pharmacy",
+ },
+ },
+ }
+ query := CredentialQuery{
+ ID: "test",
+ Claims: []ClaimsQuery{
+ {Path: []any{"credentialSubject", "patientId"}, Values: []any{"123"}},
+ {Path: []any{"credentialSubject", "type"}, Values: []any{"pharmacy"}},
+ },
+ }
+
+ result, err := Match(query, []vc.VerifiableCredential{credential})
+
+ require.NoError(t, err)
+ assert.Len(t, result, 1)
+ })
+ t.Run("multiple claims use AND semantics — one fails", func(t *testing.T) {
+ credential := vc.VerifiableCredential{
+ CredentialSubject: []map[string]any{
+ {
+ "patientId": "123",
+ "type": "hospital",
+ },
+ },
+ }
+ query := CredentialQuery{
+ ID: "test",
+ Claims: []ClaimsQuery{
+ {Path: []any{"credentialSubject", "patientId"}, Values: []any{"123"}},
+ {Path: []any{"credentialSubject", "type"}, Values: []any{"pharmacy"}},
+ },
+ }
+
+ result, err := Match(query, []vc.VerifiableCredential{credential})
+
+ require.NoError(t, err)
+ assert.Empty(t, result)
+ })
+ t.Run("missing field in credential does not match", func(t *testing.T) {
+ credential := vc.VerifiableCredential{
+ CredentialSubject: []map[string]any{
+ {"otherField": "value"},
+ },
+ }
+ query := CredentialQuery{
+ ID: "test",
+ Claims: []ClaimsQuery{
+ {Path: []any{"credentialSubject", "patientId"}, Values: []any{"123"}},
+ },
+ }
+
+ result, err := Match(query, []vc.VerifiableCredential{credential})
+
+ require.NoError(t, err)
+ assert.Empty(t, result)
+ })
+ t.Run("empty credentialSubject does not match", func(t *testing.T) {
+ credential := vc.VerifiableCredential{
+ CredentialSubject: []map[string]any{},
+ }
+ query := CredentialQuery{
+ ID: "test",
+ Claims: []ClaimsQuery{
+ {Path: []any{"credentialSubject", "patientId"}, Values: []any{"123"}},
+ },
+ }
+
+ result, err := Match(query, []vc.VerifiableCredential{credential})
+
+ require.NoError(t, err)
+ assert.Empty(t, result)
+ })
+ t.Run("empty credentials list returns empty", func(t *testing.T) {
+ query := CredentialQuery{
+ ID: "test",
+ Claims: []ClaimsQuery{{Path: []any{"credentialSubject", "id"}, Values: []any{"x"}}},
+ }
+
+ result, err := Match(query, []vc.VerifiableCredential{})
+
+ require.NoError(t, err)
+ assert.Empty(t, result)
+ })
+ t.Run("multiple credentials returns only matching ones", func(t *testing.T) {
+ match := vc.VerifiableCredential{
+ CredentialSubject: []map[string]any{{"patientId": "123"}},
+ }
+ noMatch := vc.VerifiableCredential{
+ CredentialSubject: []map[string]any{{"patientId": "456"}},
+ }
+ query := CredentialQuery{
+ ID: "test",
+ Claims: []ClaimsQuery{
+ {Path: []any{"credentialSubject", "patientId"}, Values: []any{"123"}},
+ },
+ }
+
+ result, err := Match(query, []vc.VerifiableCredential{noMatch, match, noMatch})
+
+ require.NoError(t, err)
+ assert.Len(t, result, 1)
+ assert.Equal(t, match, result[0])
+ })
+ t.Run("integer value matching", func(t *testing.T) {
+ credential := vc.VerifiableCredential{
+ CredentialSubject: []map[string]any{
+ {"age": float64(42)},
+ },
+ }
+ query := CredentialQuery{
+ ID: "test",
+ Claims: []ClaimsQuery{
+ {Path: []any{"credentialSubject", "age"}, Values: []any{float64(42)}},
+ },
+ }
+
+ result, err := Match(query, []vc.VerifiableCredential{credential})
+
+ require.NoError(t, err)
+ assert.Len(t, result, 1)
+ })
+ t.Run("boolean value matching", func(t *testing.T) {
+ credential := vc.VerifiableCredential{
+ CredentialSubject: []map[string]any{
+ {"active": true},
+ },
+ }
+ query := CredentialQuery{
+ ID: "test",
+ Claims: []ClaimsQuery{
+ {Path: []any{"credentialSubject", "active"}, Values: []any{true}},
+ },
+ }
+
+ result, err := Match(query, []vc.VerifiableCredential{credential})
+
+ require.NoError(t, err)
+ assert.Len(t, result, 1)
+ })
+ t.Run("boolean value mismatch", func(t *testing.T) {
+ credential := vc.VerifiableCredential{
+ CredentialSubject: []map[string]any{
+ {"active": false},
+ },
+ }
+ query := CredentialQuery{
+ ID: "test",
+ Claims: []ClaimsQuery{
+ {Path: []any{"credentialSubject", "active"}, Values: []any{true}},
+ },
+ }
+
+ result, err := Match(query, []vc.VerifiableCredential{credential})
+
+ require.NoError(t, err)
+ assert.Empty(t, result)
+ })
+ t.Run("claim without values matches if field exists", func(t *testing.T) {
+ credential := vc.VerifiableCredential{
+ CredentialSubject: []map[string]any{
+ {"patientId": "anything"},
+ },
+ }
+ query := CredentialQuery{
+ ID: "test",
+ Claims: []ClaimsQuery{
+ {Path: []any{"credentialSubject", "patientId"}},
+ },
+ }
+
+ result, err := Match(query, []vc.VerifiableCredential{credential})
+
+ require.NoError(t, err)
+ assert.Len(t, result, 1)
+ })
+ t.Run("claim without values does not match if field missing", func(t *testing.T) {
+ credential := vc.VerifiableCredential{
+ CredentialSubject: []map[string]any{
+ {"otherField": "value"},
+ },
+ }
+ query := CredentialQuery{
+ ID: "test",
+ Claims: []ClaimsQuery{
+ {Path: []any{"credentialSubject", "patientId"}},
+ },
+ }
+
+ result, err := Match(query, []vc.VerifiableCredential{credential})
+
+ require.NoError(t, err)
+ assert.Empty(t, result)
+ })
+ // This test verifies that matching works correctly when both the query and credential
+ // are deserialized from JSON, as they would be in production. JSON unmarshalling can
+ // produce different Go types than hand-constructed structs (e.g., nested maps vs typed
+ // fields), so this catches type mismatches that unit tests with Go literals would miss.
+ t.Run("JSON-deserialized query matches JSON-deserialized credential", func(t *testing.T) {
+ var credential vc.VerifiableCredential
+ err := credential.UnmarshalJSON([]byte(`{
+ "type": ["VerifiableCredential", "PatientEnrollmentCredential"],
+ "credentialSubject": [{
+ "hasEnrollment": {
+ "patient": {
+ "identifier": {
+ "system": "http://fhir.nl/fhir/NamingSystem/bsn",
+ "value": "123456789"
+ }
+ }
+ }
+ }]
+ }`))
+ require.NoError(t, err)
+
+ var query CredentialQuery
+ err = json.Unmarshal([]byte(`{
+ "id": "id_patient_enrollment",
+ "claims": [
+ {
+ "path": ["credentialSubject", "hasEnrollment", "patient", "identifier", "value"],
+ "values": ["123456789"]
+ }
+ ]
+ }`), &query)
+ require.NoError(t, err)
+
+ result, err := Match(query, []vc.VerifiableCredential{credential})
+
+ require.NoError(t, err)
+ assert.Len(t, result, 1)
+ })
+ t.Run("path with integer element resolves array index", func(t *testing.T) {
+ credential := vc.VerifiableCredential{
+ CredentialSubject: []map[string]any{
+ {
+ "roleCodes": []any{"01.015", "30.000", "17.000"},
+ },
+ },
+ }
+ query := CredentialQuery{
+ ID: "test",
+ Claims: []ClaimsQuery{
+ // Select the second role code (30.000 = Verpleegkundige)
+ {Path: []any{"credentialSubject", "roleCodes", 1}, Values: []any{"30.000"}},
+ },
+ }
+
+ result, err := Match(query, []vc.VerifiableCredential{credential})
+
+ require.NoError(t, err)
+ assert.Len(t, result, 1)
+ })
+ t.Run("path with integer element does not match wrong index", func(t *testing.T) {
+ credential := vc.VerifiableCredential{
+ CredentialSubject: []map[string]any{
+ {
+ "roleCodes": []any{"01.015", "30.000", "17.000"},
+ },
+ },
+ }
+ query := CredentialQuery{
+ ID: "test",
+ Claims: []ClaimsQuery{
+ // Index 1 is "30.000" (Verpleegkundige), not "17.000" (Apotheker)
+ {Path: []any{"credentialSubject", "roleCodes", 1}, Values: []any{"17.000"}},
+ },
+ }
+
+ result, err := Match(query, []vc.VerifiableCredential{credential})
+
+ require.NoError(t, err)
+ assert.Empty(t, result)
+ })
+ t.Run("null wildcard matches value anywhere in array", func(t *testing.T) {
+ credential := vc.VerifiableCredential{
+ CredentialSubject: []map[string]any{
+ {
+ "roleCodes": []any{"01.015", "30.000", "17.000"},
+ },
+ },
+ }
+ query := CredentialQuery{
+ ID: "test",
+ Claims: []ClaimsQuery{
+ // null wildcard selects all elements — match if "30.000" (Verpleegkundige) is anywhere in the array
+ {Path: []any{"credentialSubject", "roleCodes", nil}, Values: []any{"30.000"}},
+ },
+ }
+
+ result, err := Match(query, []vc.VerifiableCredential{credential})
+
+ require.NoError(t, err)
+ assert.Len(t, result, 1)
+ })
+ t.Run("null wildcard does not match when value absent from array", func(t *testing.T) {
+ credential := vc.VerifiableCredential{
+ CredentialSubject: []map[string]any{
+ {
+ "roleCodes": []any{"01.015", "30.000", "17.000"},
+ },
+ },
+ }
+ query := CredentialQuery{
+ ID: "test",
+ Claims: []ClaimsQuery{
+ // "89.000" (Dietist) is not in the array
+ {Path: []any{"credentialSubject", "roleCodes", nil}, Values: []any{"89.000"}},
+ },
+ }
+
+ result, err := Match(query, []vc.VerifiableCredential{credential})
+
+ require.NoError(t, err)
+ assert.Empty(t, result)
+ })
+ t.Run("null wildcard in middle of path matches nested field in array elements", func(t *testing.T) {
+ credential := vc.VerifiableCredential{
+ CredentialSubject: []map[string]any{
+ {
+ "qualifications": []any{
+ map[string]any{"roleCode": "01.015", "name": "Huisarts"},
+ map[string]any{"roleCode": "30.000", "name": "Verpleegkundige"},
+ map[string]any{"roleCode": "17.000", "name": "Apotheker"},
+ },
+ },
+ },
+ }
+ query := CredentialQuery{
+ ID: "test",
+ Claims: []ClaimsQuery{
+ // Wildcard selects all qualifications, then match on nested roleCode
+ {Path: []any{"credentialSubject", "qualifications", nil, "roleCode"}, Values: []any{"30.000"}},
+ },
+ }
+
+ result, err := Match(query, []vc.VerifiableCredential{credential})
+
+ require.NoError(t, err)
+ assert.Len(t, result, 1)
+ })
+ t.Run("null wildcard in middle of path does not match when nested field absent", func(t *testing.T) {
+ credential := vc.VerifiableCredential{
+ CredentialSubject: []map[string]any{
+ {
+ "qualifications": []any{
+ map[string]any{"roleCode": "01.015", "name": "Huisarts"},
+ map[string]any{"roleCode": "30.000", "name": "Verpleegkundige"},
+ },
+ },
+ },
+ }
+ query := CredentialQuery{
+ ID: "test",
+ Claims: []ClaimsQuery{
+ // "89.000" (Dietist) is not in any qualification's roleCode
+ {Path: []any{"credentialSubject", "qualifications", nil, "roleCode"}, Values: []any{"89.000"}},
+ },
+ }
+
+ result, err := Match(query, []vc.VerifiableCredential{credential})
+
+ require.NoError(t, err)
+ assert.Empty(t, result)
+ })
+ t.Run("nested wildcards match values in arrays of arrays", func(t *testing.T) {
+ // A credential with departments, each containing multiple employees with role codes
+ credential := vc.VerifiableCredential{
+ CredentialSubject: []map[string]any{
+ {
+ "departments": []any{
+ map[string]any{
+ "employees": []any{
+ map[string]any{"roleCode": "01.015"},
+ map[string]any{"roleCode": "30.000"},
+ },
+ },
+ map[string]any{
+ "employees": []any{
+ map[string]any{"roleCode": "17.000"},
+ map[string]any{"roleCode": "04.000"},
+ },
+ },
+ },
+ },
+ },
+ }
+ query := CredentialQuery{
+ ID: "test",
+ Claims: []ClaimsQuery{
+ // Two wildcards: all departments → all employees → roleCode
+ // Should find "17.000" (Apotheker) in the second department
+ {Path: []any{"credentialSubject", "departments", nil, "employees", nil, "roleCode"}, Values: []any{"17.000"}},
+ },
+ }
+
+ result, err := Match(query, []vc.VerifiableCredential{credential})
+
+ require.NoError(t, err)
+ assert.Len(t, result, 1)
+ })
+ t.Run("path resolves root-level fields", func(t *testing.T) {
+ credential := vc.VerifiableCredential{
+ Issuer: ssi.MustParseURI("did:x509:0:sha256:abc123"),
+ }
+ query := CredentialQuery{
+ ID: "test",
+ Claims: []ClaimsQuery{
+ {Path: []any{"issuer"}, Values: []any{"did:x509:0:sha256:abc123"}},
+ },
+ }
+
+ result, err := Match(query, []vc.VerifiableCredential{credential})
+
+ require.NoError(t, err)
+ assert.Len(t, result, 1)
+ })
+ t.Run("invalid credential query ID returns error", func(t *testing.T) {
+ query := CredentialQuery{
+ ID: "invalid id!",
+ Claims: []ClaimsQuery{},
+ }
+
+ _, err := Match(query, []vc.VerifiableCredential{})
+
+ assert.ErrorContains(t, err, "invalid credential query id")
+ })
+ t.Run("empty credential query ID returns error", func(t *testing.T) {
+ query := CredentialQuery{
+ ID: "",
+ Claims: []ClaimsQuery{},
+ }
+
+ _, err := Match(query, []vc.VerifiableCredential{})
+
+ assert.ErrorContains(t, err, "invalid credential query id")
+ })
+ t.Run("float path element that is not an integer returns error", func(t *testing.T) {
+ credential := vc.VerifiableCredential{
+ CredentialSubject: []map[string]any{
+ {"roles": []any{"01.015", "30.000"}},
+ },
+ }
+ query := CredentialQuery{
+ ID: "test",
+ Claims: []ClaimsQuery{
+ {Path: []any{"credentialSubject", "roles", float64(1.5)}, Values: []any{"30.000"}},
+ },
+ }
+
+ _, err := Match(query, []vc.VerifiableCredential{credential})
+
+ assert.ErrorContains(t, err, "invalid path element")
+ })
+ t.Run("boolean path element returns error", func(t *testing.T) {
+ credential := vc.VerifiableCredential{
+ CredentialSubject: []map[string]any{
+ {"field": "value"},
+ },
+ }
+ query := CredentialQuery{
+ ID: "test",
+ Claims: []ClaimsQuery{
+ {Path: []any{"credentialSubject", true}, Values: []any{"value"}},
+ },
+ }
+
+ _, err := Match(query, []vc.VerifiableCredential{credential})
+
+ assert.ErrorContains(t, err, "invalid path element type")
+ })
+ t.Run("single credentialSubject with explicit index 0 works", func(t *testing.T) {
+ credential := vc.VerifiableCredential{
+ CredentialSubject: []map[string]any{
+ {"patientId": "123"},
+ },
+ }
+ query := CredentialQuery{
+ ID: "test",
+ Claims: []ClaimsQuery{
+ {Path: []any{"credentialSubject", 0, "patientId"}, Values: []any{"123"}},
+ },
+ }
+
+ result, err := Match(query, []vc.VerifiableCredential{credential})
+
+ require.NoError(t, err)
+ assert.Len(t, result, 1)
+ })
+ t.Run("multiple credentialSubjects without index returns error", func(t *testing.T) {
+ credential := vc.VerifiableCredential{
+ CredentialSubject: []map[string]any{
+ {"patientId": "123"},
+ {"patientId": "456"},
+ },
+ }
+ query := CredentialQuery{
+ ID: "test",
+ Claims: []ClaimsQuery{
+ {Path: []any{"credentialSubject", "patientId"}, Values: []any{"123"}},
+ },
+ }
+
+ _, err := Match(query, []vc.VerifiableCredential{credential})
+
+ assert.ErrorContains(t, err, "index")
+ })
+ t.Run("multiple credentialSubjects with index works", func(t *testing.T) {
+ credential := vc.VerifiableCredential{
+ CredentialSubject: []map[string]any{
+ {"patientId": "123"},
+ {"patientId": "456"},
+ },
+ }
+ query := CredentialQuery{
+ ID: "test",
+ Claims: []ClaimsQuery{
+ {Path: []any{"credentialSubject", 1, "patientId"}, Values: []any{"456"}},
+ },
+ }
+
+ result, err := Match(query, []vc.VerifiableCredential{credential})
+
+ require.NoError(t, err)
+ assert.Len(t, result, 1)
+ })
+ t.Run("negative int path element returns error", func(t *testing.T) {
+ credential := vc.VerifiableCredential{
+ CredentialSubject: []map[string]any{
+ {"roles": []any{"01.015", "30.000"}},
+ },
+ }
+ query := CredentialQuery{
+ ID: "test",
+ Claims: []ClaimsQuery{
+ {Path: []any{"credentialSubject", "roles", -1}, Values: []any{"30.000"}},
+ },
+ }
+
+ _, err := Match(query, []vc.VerifiableCredential{credential})
+
+ assert.ErrorContains(t, err, "invalid path element")
+ })
+ t.Run("float64 exceeding MaxInt returns error", func(t *testing.T) {
+ credential := vc.VerifiableCredential{
+ CredentialSubject: []map[string]any{
+ {"roles": []any{"01.015"}},
+ },
+ }
+ query := CredentialQuery{
+ ID: "test",
+ Claims: []ClaimsQuery{
+ {Path: []any{"credentialSubject", "roles", float64(1e19)}, Values: []any{"01.015"}},
+ },
+ }
+
+ _, err := Match(query, []vc.VerifiableCredential{credential})
+
+ assert.ErrorContains(t, err, "invalid path element")
+ })
+ t.Run("empty path in claims query returns error", func(t *testing.T) {
+ credential := vc.VerifiableCredential{
+ CredentialSubject: []map[string]any{
+ {"field": "value"},
+ },
+ }
+ query := CredentialQuery{
+ ID: "test",
+ Claims: []ClaimsQuery{
+ {Path: []any{}, Values: []any{"value"}},
+ },
+ }
+
+ _, err := Match(query, []vc.VerifiableCredential{credential})
+
+ assert.ErrorContains(t, err, "path must be a non-empty array")
+ })
+ t.Run("path ending at object does not panic on value comparison", func(t *testing.T) {
+ // When the path resolves to a map (non-comparable type), containsExpectedValue
+ // must not panic on == comparison. It should simply not match.
+ credential := vc.VerifiableCredential{
+ CredentialSubject: []map[string]any{
+ {
+ "hasEnrollment": map[string]any{
+ "patient": map[string]any{
+ "name": "John",
+ },
+ },
+ },
+ },
+ }
+ query := CredentialQuery{
+ ID: "test",
+ Claims: []ClaimsQuery{
+ // Path stops at the object level, resolved value is a map
+ {Path: []any{"credentialSubject", "hasEnrollment", "patient"}, Values: []any{"John"}},
+ },
+ }
+
+ result, err := Match(query, []vc.VerifiableCredential{credential})
+
+ require.NoError(t, err)
+ assert.Empty(t, result)
+ })
+ t.Run("single credentialSubject with non-zero index does not match", func(t *testing.T) {
+ // When credentialSubject is a single object and the path uses index 5,
+ // this should not match — only index 0 is valid for a single element.
+ credential := vc.VerifiableCredential{
+ CredentialSubject: []map[string]any{
+ {"patientId": "123"},
+ },
+ }
+ query := CredentialQuery{
+ ID: "test",
+ Claims: []ClaimsQuery{
+ {Path: []any{"credentialSubject", 5, "patientId"}, Values: []any{"123"}},
+ },
+ }
+
+ result, err := Match(query, []vc.VerifiableCredential{credential})
+
+ require.NoError(t, err)
+ assert.Empty(t, result)
+ })
+ t.Run("null wildcard on non-array value does not match", func(t *testing.T) {
+ credential := vc.VerifiableCredential{
+ CredentialSubject: []map[string]any{
+ {"name": "John"},
+ },
+ }
+ query := CredentialQuery{
+ ID: "test",
+ Claims: []ClaimsQuery{
+ // null wildcard on a string value — not an array
+ {Path: []any{"credentialSubject", "name", nil}, Values: []any{"John"}},
+ },
+ }
+
+ result, err := Match(query, []vc.VerifiableCredential{credential})
+
+ require.NoError(t, err)
+ assert.Empty(t, result)
+ })
+ t.Run("integer index on non-array value does not match", func(t *testing.T) {
+ credential := vc.VerifiableCredential{
+ CredentialSubject: []map[string]any{
+ {"name": "John"},
+ },
+ }
+ query := CredentialQuery{
+ ID: "test",
+ Claims: []ClaimsQuery{
+ {Path: []any{"credentialSubject", "name", 0}, Values: []any{"John"}},
+ },
+ }
+
+ result, err := Match(query, []vc.VerifiableCredential{credential})
+
+ require.NoError(t, err)
+ assert.Empty(t, result)
+ })
+ t.Run("out-of-bounds array index does not match", func(t *testing.T) {
+ credential := vc.VerifiableCredential{
+ CredentialSubject: []map[string]any{
+ {"roles": []any{"01.015"}},
+ },
+ }
+ query := CredentialQuery{
+ ID: "test",
+ Claims: []ClaimsQuery{
+ {Path: []any{"credentialSubject", "roles", 99}, Values: []any{"01.015"}},
+ },
+ }
+
+ result, err := Match(query, []vc.VerifiableCredential{credential})
+
+ require.NoError(t, err)
+ assert.Empty(t, result)
+ })
+ t.Run("string key on non-map non-array value does not match", func(t *testing.T) {
+ credential := vc.VerifiableCredential{
+ CredentialSubject: []map[string]any{
+ {"count": float64(42)},
+ },
+ }
+ query := CredentialQuery{
+ ID: "test",
+ Claims: []ClaimsQuery{
+ // Trying to use a string key on a number
+ {Path: []any{"credentialSubject", "count", "sub"}, Values: []any{"x"}},
+ },
+ }
+
+ result, err := Match(query, []vc.VerifiableCredential{credential})
+
+ require.NoError(t, err)
+ assert.Empty(t, result)
+ })
+ t.Run("valid credential query ID with hyphens and underscores", func(t *testing.T) {
+ query := CredentialQuery{
+ ID: "id_patient-enrollment_01",
+ Claims: []ClaimsQuery{},
+ }
+
+ result, err := Match(query, []vc.VerifiableCredential{})
+
+ require.NoError(t, err)
+ assert.Empty(t, result)
+ })
+}
+
+func BenchmarkMatch_2000Credentials(b *testing.B) {
+ const count = 2000
+ roleCodes := []string{"01.015", "30.000", "17.000", "04.000", "89.000"}
+ credentials := make([]vc.VerifiableCredential, count)
+ for i := range credentials {
+ bsn := fmt.Sprintf("%09d", i)
+ ura := fmt.Sprintf("URA-%05d", i)
+ // Each credential has multiple identifiers and multiple qualifications with role codes
+ credentials[i] = vc.VerifiableCredential{
+ Type: []ssi.URI{ssi.MustParseURI("VerifiableCredential"), ssi.MustParseURI("PatientEnrollmentCredential")},
+ Issuer: ssi.MustParseURI(fmt.Sprintf("did:x509:0:sha256:hash%d", i)),
+ CredentialSubject: []map[string]any{
+ {
+ "identifier": []any{
+ map[string]any{"system": "http://fhir.nl/fhir/NamingSystem/ura", "value": ura},
+ map[string]any{"system": "http://fhir.nl/fhir/NamingSystem/bsn", "value": bsn},
+ },
+ "qualifications": []any{
+ map[string]any{"roleCode": roleCodes[i%len(roleCodes)], "name": "Role A"},
+ map[string]any{"roleCode": roleCodes[(i+1)%len(roleCodes)], "name": "Role B"},
+ map[string]any{"roleCode": roleCodes[(i+2)%len(roleCodes)], "name": "Role C"},
+ },
+ },
+ },
+ }
+ }
+ // Worst case: target is the last credential. Multiple claims with wildcards.
+ targetBsn := fmt.Sprintf("%09d", count-1)
+ query := CredentialQuery{
+ ID: "id_patient_enrollment",
+ Claims: []ClaimsQuery{
+ // Wildcard over identifiers to find BSN
+ {
+ Path: []any{"credentialSubject", "identifier", nil, "value"},
+ Values: []any{targetBsn},
+ },
+ // Wildcard over qualifications to find a specific role code
+ {
+ Path: []any{"credentialSubject", "qualifications", nil, "roleCode"},
+ Values: []any{roleCodes[(count-1)%len(roleCodes)]},
+ },
+ },
+ }
+
+ b.ResetTimer()
+ var benchResult []vc.VerifiableCredential
+ var benchErr error
+ for b.Loop() {
+ benchResult, benchErr = Match(query, credentials)
+ }
+ require.NoError(b, benchErr)
+ require.Len(b, benchResult, 1)
+}