Skip to content

#4087: DCQL credential query parser#4091

Closed
stevenvegt wants to merge 21 commits intofeature/4067-credential-selection-dcqlfrom
feature/4087-dcql-parser
Closed

#4087: DCQL credential query parser#4091
stevenvegt wants to merge 21 commits intofeature/4067-credential-selection-dcqlfrom
feature/4087-dcql-parser

Conversation

@stevenvegt
Copy link
Member

@stevenvegt stevenvegt commented Mar 23, 2026

Summary

  • Introduces vcr/dcql package implementing a subset of DCQL (Digital Credentials Query Language) from OpenID4VP sections 6.1, 6.3, and 7
  • Match(query, credentials) function evaluates a credential query against a list of VCs and returns matches
  • Supports Claims Path Pointer with string (key lookup), integer (array index), and null (wildcard) elements
  • Supports exact value matching with OR semantics, multiple claims with AND semantics
  • Validates credential query ID per spec (alphanumeric/underscore/hyphen)
  • Handles credentialSubject serialization duality (single object vs array) transparently
  • Unsupported DCQL features (format, meta, trusted_authorities, etc.) are documented — they are handled by other layers (PD matching, VP verification)
  • 43 unit tests + 1 benchmark (~24ms worst case for 2000 credentials)

Closes #4087
Part of #4067

Test plan

  • Single value matching (match and no-match)
  • Nested path resolution
  • Multiple values OR semantics
  • Multiple claims AND semantics
  • Missing fields and empty inputs
  • Integer and boolean value types
  • Existence check (claim without values)
  • JSON-deserialized query and credential compatibility
  • Array index path elements
  • Null wildcard path elements (end and middle of path)
  • Nested wildcards (arrays of arrays)
  • Root-level field resolution (issuer, type)
  • ID validation (empty, invalid characters, valid)
  • credentialSubject normalization (single with/without index, multiple with/without index)
  • Non-zero index on single credentialSubject does not match
  • Error cases (negative int, non-integer float, MaxInt overflow, empty path, boolean path, unsupported type)
  • Edge cases (null wildcard on non-array, int index on non-array, out-of-bounds index, string key on scalar, path ending at object)
  • Benchmark: 2000 credentials worst case with multiple claims and wildcards

stevenvegt and others added 15 commits March 21, 2026 18:18
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>
@qltysh
Copy link

qltysh bot commented Mar 23, 2026

2 new issues

Tool Category Rule Count
qlty Structure Function with many returns (count = 9): normalizeCredentialSubjectPath 2

@stevenvegt stevenvegt linked an issue Mar 23, 2026 that may be closed by this pull request
16 tasks
@qltysh
Copy link

qltysh bot commented Mar 23, 2026

Qlty

Coverage Impact

⬆️ Merging this pull request will increase total coverage on feature/4067-credential-selection-dcql by 0.01%.

Modified Files with Diff Coverage (1)

RatingFile% DiffUncovered Line #s
New file Coverage rating: B
vcr/dcql/dcql.go86.4%89-90, 118-119...
Total86.4%
🤖 Increase coverage with AI coding...

In the `feature/4087-dcql-parser` branch, add test coverage for this new code:

- `vcr/dcql/dcql.go` -- Lines 89-90, 118-119, 122-123, 143-144, 147-148, 165-166, 184-189, 192-193, 279, 293-294, and 300-301

🚦 See full report on Qlty Cloud »

🛟 Help
  • Diff Coverage: Coverage for added or modified lines of code (excludes deleted files). Learn more.

  • Total Coverage: Coverage for the whole repository, calculated as the sum of all File Coverage. Learn more.

  • File Coverage: Covered Lines divided by Covered Lines plus Missed Lines. (Excludes non-executable lines including blank lines and comments.)

    • Indirect Changes: Changes to File Coverage for files that were not modified in this PR. Learn more.

vcr/dcql/dcql.go Outdated
}

func validateQuery(query CredentialQuery) error {
if query.ID == "" {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

regex already requires length >= 1

vcr/dcql/dcql.go Outdated
Comment on lines +157 to +158
// contains exactly one entry. This allows paths like ["credentialSubject", "patientId"]
// instead of ["credentialSubject", 0, "patientId"].
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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"]}
      ]
    }
  ]
}

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@reinkrul reinkrul requested a review from Copilot March 24, 2026 05:43
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>
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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, null wildcards) 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.

Comment on lines +189 to +194
case nil:
// Null wildcard: select all elements of the array
arr, ok := value.([]any)
if !ok {
return nil
}
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
vcr/dcql/dcql.go Outdated
Comment on lines +199 to +209
// 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
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
- 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>
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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)
}

Copilot uses AI. Check for mistakes.
vcr/dcql/dcql.go Outdated
Comment on lines +200 to +202
// 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)
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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)

Copilot uses AI. Check for mistakes.
Comment on lines +158 to +176
// 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
}
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +144 to +156
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
}
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
stevenvegt and others added 3 commits March 25, 2026 08:40
- 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>
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +227 to +233
if value == expected {
return true
}
}
return false
}

Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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
}
}

Copilot uses AI. Check for mistakes.
Comment on lines +150 to +165
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
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
…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>
Copy link
Member

@reinkrul reinkrul left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

waiting for copilot's feedback tob e addressed

@stevenvegt
Copy link
Member Author

Closed in favor of a simpler #4121 approach.

@stevenvegt stevenvegt closed this Mar 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

DCQL credential query parser with claims/values matching

3 participants