Skip to content

[#4253 2/8] feat(pe): PD validators (Validate + ValidateSelectionKeys)#4281

Draft
stevenvegt wants to merge 8 commits into
feature/4253-credential-selectionfrom
4253-2-validators
Draft

[#4253 2/8] feat(pe): PD validators (Validate + ValidateSelectionKeys)#4281
stevenvegt wants to merge 8 commits into
feature/4253-credential-selectionfrom
4253-2-validators

Conversation

@stevenvegt

@stevenvegt stevenvegt commented May 27, 2026

Copy link
Copy Markdown
Member

Parent PRD

#4253

Item 2 of 8. Independent of item #1 (branches off the feature branch; can proceed in parallel).

Summary

Add two pure validators to vcr/pe: Validate (Policy 7, load-time PD consistency) and ValidateSelectionKeys (Policy 1, credential_selection key validation). Both are pure functions with typed errors and aggregate-all reporting. No caller is wired in this PR — the loader (policy/, discovery/) consumes Validate in item #7, and the API handlers consume ValidateSelectionKeys in item #6.

Implementation Spec

func Validate(pd PresentationDefinition) error — Policy 7

Semantic checks that JSON Schema cannot express. The PD has already passed schema validation (v2.Validate, which checks structure: required fields, types, additionalProperties, etc.) before this runs; Validate is a second, cross-field semantic pass on the parsed PresentationDefinition.

  • Id uniqueness within a constraints object. Within a single descriptor's Constraints.Fields, no two fields may share the same id (PEX requirement; not expressible in the JSON schema).
  • Same-id value-set consistency. For every field id appearing on two or more field-objects across descriptors, the value it binds to must satisfy every field's filter at once, so the intersection of the fields' allowed-value sets must be non-empty. Each filter is modelled as a value set:
    • const c{c}; enum [...] → that finite set; type-only / no filter → the universe of that type; pattern → a regex predicate.
    • Type agreement first: all declared Filter.Type for the id must be equal (mixed types can never agree; pins the type).
    • Finite-set intersection when any field for the id declares a const or enum: compute the intersection of all consts (as singletons) and all enum sets, then keep only values matching every declared pattern. Empty result → conflict. This catches differing consts, const-not-in-enum, disjoint enums, const-fails-pattern, and no-enum-value-matches-pattern.
    • Deferred — pattern vs pattern only. When an id carries only type + one or more patterns (no const/enum), the intersection is undecidable (regexp2 is not a regular language), so it is not checked at load. This degrades gracefully: at request time no credential can satisfy both patterns, the match fails, and the MatchReport from added baseline funcs from old repo #1 surfaces the no-eligible-candidate / binding conflict with the path and value.
    • Differing paths are always allowed (same id resolved from different JSONPaths on different credential types).
  • Aggregate-all: every conflicting id in the PD is reported in one error.

func ValidateSelectionKeys(selection map[string]string, pds ...PresentationDefinition) error — Policy 1

  • Builds the union of field ids across all supplied PDs (variadic: one PD for single-VP, two for two-VP).
  • Any selection key not in that union is unknown.
  • Validates key names only; values (empty or not) are ignored. This resolves the PRD open question on empty-value entries: an empty-string value does not change key validation.
  • Lists all unknown keys, sorted for determinism.

Typed errors

// Returned by ValidateSelectionKeys. nil when all keys are known.
type UnknownSelectionKeysError struct {
    Keys []string // sorted; every supplied key not present as a field id in any PD
}
func (e *UnknownSelectionKeysError) Error() string // "unknown credential_selection keys: a, b"

// Returned by Validate. nil when the PD is consistent.
type PDValidationError struct {
    Conflicts []FieldIDConflict
}
type FieldIDConflict struct {
    FieldID string
    Kind    string // "duplicate" | "type" | "unsatisfiable"
    Detail  string // e.g. `conflicting filter types: string vs number`; `no value satisfies all filters: const "Z" not in enum [X Y]`
}
func (e *PDValidationError) Error() string // aggregates all conflicts

Typed errors let item #6 build the OAuth invalid_request description (and #7 log structured detail) without string-parsing; the Error() strings stay human-readable for logs and operators.

Deviations from spec (decided with the user during implementation, 2026-07-16)

  • Typed error renamed: FieldIDConflict{FieldID} became ValidationConflict{Subject}; the
    subject can be a field id, an input descriptor id, a group, or a submission requirement
    position. PDValidationError gained PDID. Kind is a typed ConflictKind with constants,
    extended with invalid_pattern, ignored_constraint and submission_requirement.
  • Scope additions (all follow the spec's fail-deterministically-at-boot rationale):
    • Single-filter sanity: filters that can never match (const on non-string type, empty enum,
      const failing its own pattern), patterns that error (non-compiling, more than one capture
      group), and constraints the matcher silently ignores (const/pattern shadowed by enum,
      pattern on non-string types, unrecognized type values such as "integer", unsupported JSON
      Schema keywords such as "minimum"). Filter parsing stays lenient and records unsupported
      keywords for Validate; JSON Schema annotation keywords (description, title, ...) are exempt.
    • Structural checks: duplicate input descriptor ids, descriptor groups not covered by any
      submission requirement, submission requirement sanity (unknown rule, from/from_nested,
      bounds, rules demanding credentials from a group no descriptor carries).
    • Drive-by fix: matchFilter panicked on array values with a string+pattern filter when no
      element matched (reachable per request); now a plain non-match.
    • Docs: filter-semantics truth table in the Validate godoc; a "Presentation definition
      validation" section in docs/pages/deployment/policy.rst (describes behavior that item 7
      wires into the loader; both land in the same release).
  • The wider filter semantics question (JSON Schema alignment, numeric constraints, DCQL) is
    deliberately NOT addressed here; filed as Align presentation definition filter evaluation with JSON Schema semantics #4413.

Testing

Black-box validate_test.go, no mocks or fixtures.

  • Validate:
    • duplicate id within one constraints object;
    • conflicting filter types across same-id fields;
    • value-set conflicts: differing consts; const not in an enum; disjoint enums; const that fails another field's pattern; enum with no member matching another field's pattern;
    • allowed: path-only difference; no-filter / type-only field (no constraint); single-occurrence id (no-op); compatible const+enum (const in enum); pattern-vs-pattern with no const/enum (not flagged — deferred);
    • multiple conflicts all reported in one PDValidationError.
  • ValidateSelectionKeys: unknown key against a single PD; unknown key against a two-PD union; a key targeting only one side of a two-PD union passes; all-known passes; empty-string value validated by name; multiple unknown keys all listed and sorted.

Acceptance Criteria

  • Validate enforces id-uniqueness-within-constraints and same-id value-set non-empty intersection (type/const/enum + concrete-value-vs-pattern); defers only pattern-vs-pattern; ignores path differences.
  • ValidateSelectionKeys validates key names against the union of the supplied PDs; ignores values.
  • Both return the specified typed errors and aggregate all problems.
  • No callers wired (no behavior change outside vcr/pe); existing vcr/pe tests still green.
  • go build ./... and go test ./vcr/pe/... pass.

@qltysh

qltysh Bot commented May 27, 2026

Copy link
Copy Markdown
Contributor

Qlty


Coverage Impact

⬆️ Merging this pull request will increase total coverage on feature/4253-credential-selection by 0.12%.

Modified Files with Diff Coverage (3)

RatingFile% DiffUncovered Line #s
Coverage rating: A Coverage rating: A
vcr/pe/presentation_definition.go100.0%
New Coverage rating: C
vcr/pe/types.go78.9%150-151, 155-156
New Coverage rating: A
vcr/pe/validate.go95.4%119, 216-220...
Total94.7%
🤖 Increase coverage with AI coding...
In the `4253-2-validators` branch, add test coverage for this new code:

- `vcr/pe/types.go` -- Lines 150-151 and 155-156
- `vcr/pe/validate.go` -- Lines 119, 216-220, 475-476, 498-503, 554-555, and 582

🚦 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.

credential_selection keys are validated against the union of field ids
across the supplied presentation definitions (one for single-VP, two
for two-VP), by name only. All unknown keys are reported, sorted, in a
typed UnknownSelectionKeysError so the API layer can build the OAuth
invalid_request description without string parsing.

Assisted-by: AI
A field id used across descriptors binds one value, so every filter
carrying the id must be able to accept that value at once. Filters are
modelled as accepted-value sets following the matcher's actual
semantics (enum shadows other keywords; const and pattern compare as
strings): declared types must agree, and whenever a const or enum pins
a finite candidate set, the intersection filtered through every
declared pattern must be non-empty. Only pattern-versus-pattern is
deferred to request time. Also checks id uniqueness within a
constraints object and input descriptor id uniqueness. All conflicts
are aggregated, sorted, in a typed PDValidationError.

Assisted-by: AI
Single-filter checks following the matcher's actual semantics: a const
on a non-string type and an empty enum can never match; a pattern that
does not compile, or carries more than one capture group, errors at
request time. Constraints the matcher silently ignores (const or
pattern shadowed by enum, a non-string type next to enum, a pattern on
a non-string type) are reported as ignored_constraint: the filter is
weaker than its author declared. Filter parsing now records unsupported
JSON Schema keywords (minimum, allOf, ...) instead of dropping them
silently; parsing stays lenient so remote presentation definitions keep
working, and only Validate reports them.

Assisted-by: AI
Structural mistakes that fail every request at runtime now fail at
validation instead: a descriptor group not covered by any submission
requirement, unknown rules, from/from_nested violations, negative or
inverted bounds, and a pick rule demanding credentials from a group no
descriptor carries. Nested requirements are walked recursively. Groups
without any submission requirements stay untouched: the matcher
ignores them in all-required mode.

Assisted-by: AI
An array whose elements all fail the filter falls through the type
switch, and the pattern check asserted the value to be a string: a
crafted or unlucky combination of a pattern filter and a non-matching
array claim (e.g. a filter on $.type) crashed the matcher per request.
Non-string values are now a plain non-match.

Assisted-by: AI
The Validate godoc carries the matcher's filter truth table (enum
shadows other keywords, const and pattern are string-only, numeric
values cannot be value-constrained) so the rules the validator encodes
are stated next to the code that enforces them. The policy deployment
page gets a validation section for operators and policy authors: what
is checked at startup, an example aggregated error, and authoring
guidance (issue filterable claims as strings, declare ids, keep same-id
filters compatible).

Assisted-by: AI
Unrecognized filter type values (e.g. "integer", valid JSON Schema but
not evaluated by the matcher) are now flagged; annotation keywords
(description, title, examples, ...) are no longer captured as
unsupported, so they cannot fail a valid PD. FieldIDConflict is renamed
to ValidationConflict with a Subject field: it names field ids,
descriptor ids, groups or submission requirement positions, and a field
called FieldID holding four kinds of identifier misleads every
consumer. The docs section is promoted to a top-level heading, Error()
methods gained doc comments, and tests were added for three-way
intersections, dead filters in shared ids, duplicate participation, the
stable error string, the production parse path for unsupported
keywords, and the array/pattern guard shapes.

Assisted-by: AI
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.

1 participant