#4087: DCQL credential query parser#4091
#4087: DCQL credential query parser#4091stevenvegt wants to merge 21 commits intofeature/4067-credential-selection-dcqlfrom
Conversation
Introduces vcr/dcql package with CredentialQuery/ClaimsQuery types and Match function. First test: single claim with matching value on a flat credentialSubject returns the credential. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Verifies matching works when both query and credential come from JSON unmarshalling, catching type mismatches that Go literal tests would miss. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Match now returns ([]vc.VerifiableCredential, error). Validates credential query ID per spec: non-empty, alphanumeric/underscore/hyphen. Package documentation describes supported DCQL subset. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Path now starts at the credential root (per OpenID4VP section 7), not hardcoded to credentialSubject. Credential is marshalled to a generic JSON map for path walking. Supports top-level fields like issuer and type. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Path elements now support strings (key lookup), integers (array index), and float64 (JSON-deserialized integers). credentialSubject is unwrapped from its Go array representation to a single object for ergonomic paths. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Null path elements select all elements of an array (OpenID4VP section 7). Supports both wildcard at end of path (returns all values) and wildcard in middle (resolves remaining path for each element, collects results). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Benchmark shows ~14ms to match 1 of 2000 PatientEnrollmentCredentials (worst case). Cost dominated by json.Marshal/Unmarshal per credential for generic root-level path resolution. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2 new issues
|
|
Coverage Impact ⬆️ Merging this pull request will increase total coverage on Modified Files with Diff Coverage (1)
🤖 Increase coverage with AI coding...🚦 See full report on Qlty Cloud » 🛟 Help
|
vcr/dcql/dcql.go
Outdated
| } | ||
|
|
||
| func validateQuery(query CredentialQuery) error { | ||
| if query.ID == "" { |
There was a problem hiding this comment.
regex already requires length >= 1
vcr/dcql/dcql.go
Outdated
| // contains exactly one entry. This allows paths like ["credentialSubject", "patientId"] | ||
| // instead of ["credentialSubject", 0, "patientId"]. |
There was a problem hiding this comment.
Wouldn't that makes our DCQL queries derive with from spec? E.g. credentialSubject is an array, but our DCQL queries arent?
That sounds like a bad idea (and I sort of expected DCQL to solve this, as its sort of a successor of PEX?)
How do other implementations handle this?
There was a problem hiding this comment.
Is the credentialSubject an array? I've looked through the examples and could only find paths without the index.
For example https://openid.net/specs/openid-4-verifiable-presentations-1_0.html#name-presentation-request
{
"credentials": [
{
"id": "example_jwt_vc",
"format": "jwt_vc_json",
"meta": {
"type_values": [["IDCredential"]]
},
"claims": [
{"path": ["credentialSubject", "family_name"]},
{"path": ["credentialSubject", "given_name"]}
]
}
]
}
There was a problem hiding this comment.
Well, it can be both... https://www.w3.org/TR/vc-data-model-1.1/#credential-subject
credentialSubject
The value of the credentialSubject property is defined as a set of objects that contain one or more properties that are each related to a subject of the verifiable credential. Each object MAY contain an id, as described in Section 4.2 Identifiers.
There was a problem hiding this comment.
The behavior was already covering this, but now it is explicit:
When the credentialSubject with length > 1, you need to use an index. If the length == 1, you can just use a string to use select a key from the credentialSubject. If you use a string when length > 1, the Match function will error.
Documents the purpose (deterministic credential selection vs spec's privacy-hinted selective disclosure), supported/unsupported DCQL features, Claims Path Pointer support, examples, and benchmark results. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds a new vcr/dcql package that parses/evaluates a supported subset of DCQL to deterministically select matching verifiable credentials.
Changes:
- Implemented
Match(query, credentials)with claims-path resolution (string keys, array indexes,nullwildcards) and OR/AND semantics. - Added unit tests and a benchmark for matching behavior and performance characteristics.
- Documented supported/unsupported DCQL features and usage examples.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 7 comments.
| File | Description |
|---|---|
| vcr/dcql/dcql.go | Core DCQL query types, query validation, claim matching, and path resolution implementation. |
| vcr/dcql/dcql_test.go | Extensive unit tests for matching semantics + worst-case benchmark for 2000 credentials. |
| vcr/dcql/README.md | Documents intended usage, supported subset, and examples/performance notes. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| case nil: | ||
| // Null wildcard: select all elements of the array | ||
| arr, ok := value.([]any) | ||
| if !ok { | ||
| return nil | ||
| } |
There was a problem hiding this comment.
With multiple null wildcards in a path, resolveInValue can return nested slices (e.g., []any{[]any{...}, ...}), but matchesClaim only compares the immediate slice elements to expected values (v == expected). This will fail to match values that are inside nested slices. Consider either flattening wildcard results in resolveInValue (so it always returns a single-level []any for wildcard paths) or making matchesClaim recursively walk nested slices when checking for expected values.
vcr/dcql/dcql.go
Outdated
| // Wildcard with remaining path — resolve each element and collect results | ||
| var results []any | ||
| for _, item := range arr { | ||
| if resolved := resolveInValue(path[1:], item); resolved != nil { | ||
| results = append(results, resolved) | ||
| } | ||
| } | ||
| if len(results) == 0 { | ||
| return nil | ||
| } | ||
| return results |
There was a problem hiding this comment.
With multiple null wildcards in a path, resolveInValue can return nested slices (e.g., []any{[]any{...}, ...}), but matchesClaim only compares the immediate slice elements to expected values (v == expected). This will fail to match values that are inside nested slices. Consider either flattening wildcard results in resolveInValue (so it always returns a single-level []any for wildcard paths) or making matchesClaim recursively walk nested slices when checking for expected values.
- Remove redundant empty ID check (regex already requires length >= 1) - Marshal credential once per Match call, not per claim - Validate float64 path elements are non-negative integers (error on 1.5) - Error on unsupported path element types (e.g., boolean) - Error on credential marshal/unmarshal failure (no silent skip) - Fix nested wildcards: recursive containsExpectedValue for nested slices - Fix README: Match returns empty slice, not error, on no matches - Fix benchmark: capture return values, add wildcards and multiple claims - Improve credentialSubject unwrap comment with spec references Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated 5 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| return nil, nil | ||
| } | ||
| return resolveInValue(path[1:], child) | ||
| case int: |
There was a problem hiding this comment.
For int path elements, negative values are currently treated as a non-match (via resolveArrayIndex returning nil) rather than an invalid query. The DCQL Claims Path Pointer only allows non-negative integers, and the float64 branch already errors for negatives.
Consider adding the same non-negative validation for the int case (and returning an invalid path element error) to make behavior consistent and fail fast on invalid queries.
| case int: | |
| case int: | |
| // Validate non-negative integer to align with Claims Path Pointer rules | |
| // and the float64 branch behavior. | |
| if element < 0 { | |
| return nil, fmt.Errorf("invalid path element: %v is not a non-negative integer", element) | |
| } |
vcr/dcql/dcql.go
Outdated
| // a non-negative integer before converting, to avoid silent truncation. | ||
| if element < 0 || math.Trunc(element) != element { | ||
| return nil, fmt.Errorf("invalid path element: %v is not a non-negative integer", element) |
There was a problem hiding this comment.
When handling float64 path elements, the code checks for non-negative integers but doesn’t guard against values larger than math.MaxInt. Converting an out-of-range float64 to int can overflow/wrap, producing incorrect indices and hard-to-debug behavior.
Consider checking element > float64(math.MaxInt) (and possibly element < float64(math.MinInt) though negatives are already rejected) and returning an invalid path element error if it’s out of range.
| // a non-negative integer before converting, to avoid silent truncation. | |
| if element < 0 || math.Trunc(element) != element { | |
| return nil, fmt.Errorf("invalid path element: %v is not a non-negative integer", element) | |
| // a non-negative integer within int range before converting, to avoid | |
| // silent truncation or overflow. | |
| if element < 0 || math.Trunc(element) != element || element > float64(math.MaxInt) { | |
| return nil, fmt.Errorf("invalid path element: %v is out of range or not a non-negative integer", element) |
| // 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 | ||
| } |
There was a problem hiding this comment.
containsExpectedValue compares any values using ==. If either the resolved value or an expected value is a non-comparable Go type (e.g., map[string]any or []any), this will panic at runtime. This can happen when a wildcard resolves to objects (maps) or when callers provide non-scalar values in a query.
Consider either (1) validating that values only contain comparable scalar types (string/bool/number/null) and that the resolved value is scalar (or an array of scalars), returning a clear error otherwise, or (2) switching to a safe equality check (e.g., reflect.DeepEqual / structured comparison) with explicit handling for numeric types.
| func matchesClaim(claim ClaimsQuery, root map[string]any) (bool, error) { | ||
| resolved, err := resolveInValue(claim.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 | ||
| } |
There was a problem hiding this comment.
resolveInValue returns the entire root value when claim.Path is empty (len==0). Since path is required by the DCQL claims query definition, accepting an empty path can lead to surprising behavior (claim matches any credential if values is empty) or a runtime panic when values is non-empty (because the resolved value is a map[string]any and will be compared).
It would be safer to validate each ClaimsQuery in validateQuery (or before calling resolveInValue) to ensure Path is present and non-empty, and return a dedicated query validation error when it is not.
- Negative int path elements now return error (consistent with float64) - float64 values exceeding math.MaxInt return error (prevent overflow) - Empty path in claims query validated upfront in validateQuery - String path element on array with >1 elements returns error with message to use an integer index (prevents ambiguous access on multi-element credentialSubject arrays) - Multiple credentialSubjects with explicit index works correctly Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Single credentialSubject is auto-unwrapped (no index needed). Multiple credentialSubjects require an explicit integer index. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
normalizeCredentialSubjectPath adjusts the path based on how credentialSubject was serialized: - Single element (object): strips explicit index 0 if present - Single element (array): inserts index 0 if missing - Multiple elements (array): requires explicit index, errors without Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if value == expected { | ||
| return true | ||
| } | ||
| } | ||
| return false | ||
| } | ||
|
|
There was a problem hiding this comment.
containsExpectedValue uses value == expected on any. If value resolves to a non-comparable type (e.g., map from a wildcard that returns objects, or a path that ends at an object), this will panic at runtime. Consider restricting comparisons to comparable scalar types (string/bool/float64/etc.) and returning false (or an error) for maps/slices; while doing so, also consider normalizing numeric comparisons so int/float64 representations of the same integer can match.
| if value == expected { | |
| return true | |
| } | |
| } | |
| return false | |
| } | |
| if valuesEqual(value, expected) { | |
| return true | |
| } | |
| } | |
| return false | |
| } | |
| // valuesEqual performs a safe equality check between two DCQL values. | |
| // It supports: | |
| // - numeric equality across int/int64/float64/json.Number | |
| // - string equality | |
| // - bool equality | |
| // For all other types (including maps and slices), it returns false to avoid panics. | |
| func valuesEqual(a, b any) bool { | |
| // Normalize numeric values so different numeric types with the same value match. | |
| if af, ok := toFloat64(a); ok { | |
| if bf, ok := toFloat64(b); ok { | |
| return af == bf | |
| } | |
| } | |
| switch av := a.(type) { | |
| case string: | |
| bv, ok := b.(string) | |
| return ok && av == bv | |
| case bool: | |
| bv, ok := b.(bool) | |
| return ok && av == bv | |
| default: | |
| // Other types (including maps/slices) are not compared structurally. | |
| return false | |
| } | |
| } | |
| // toFloat64 attempts to convert a value to float64 for numeric comparison. | |
| func toFloat64(v any) (float64, bool) { | |
| switch n := v.(type) { | |
| case float64: | |
| return n, true | |
| case int: | |
| return float64(n), true | |
| case int8: | |
| return float64(n), true | |
| case int16: | |
| return float64(n), true | |
| case int32: | |
| return float64(n), true | |
| case int64: | |
| return float64(n), true | |
| case uint: | |
| return float64(n), true | |
| case uint8: | |
| return float64(n), true | |
| case uint16: | |
| return float64(n), true | |
| case uint32: | |
| return float64(n), true | |
| case uint64: | |
| return float64(n), true | |
| case json.Number: | |
| f, err := n.Float64() | |
| if err != nil { | |
| return 0, false | |
| } | |
| return f, true | |
| default: | |
| return 0, false | |
| } | |
| } |
| 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 { | ||
| // 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 |
There was a problem hiding this comment.
normalizeCredentialSubjectPath strips any 2nd path element when credentialSubject is serialized as an object, regardless of the index value/type. This can silently treat ["credentialSubject", 1, ...] as if it were index 0 and can also drop invalid float indices (e.g., 1.5), preventing resolveInValue from returning an error. Only strip when the index is exactly 0 (int or float64 representing 0) and otherwise keep the path or return an explicit error.
…ests normalizeCredentialSubjectPath now only strips index 0 when credentialSubject is serialized as a single object. Non-zero indices correctly produce no match instead of silently resolving. Added tests for edge cases: path ending at object, null wildcard on non-array, integer index on non-array, out-of-bounds index, string key on scalar. Fixed benchmark to capture error return value. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
reinkrul
left a comment
There was a problem hiding this comment.
waiting for copilot's feedback tob e addressed
|
Closed in favor of a simpler #4121 approach. |

Summary
vcr/dcqlpackage implementing a subset of DCQL (Digital Credentials Query Language) from OpenID4VP sections 6.1, 6.3, and 7Match(query, credentials)function evaluates a credential query against a list of VCs and returns matchescredentialSubjectserialization duality (single object vs array) transparentlyCloses #4087
Part of #4067
Test plan