Selector Rules
Selector rules are the reconciliation engine's decision table. Each rule specifies a set of conditions and a plan to execute when those conditions match. Rules are evaluated in priority order, and the first matching rule determines the outcome. This declarative approach means reconciliation behavior can be changed entirely through configuration -- no code changes, no redeployment.
The rule engine is intentionally simple. There are no nested conditionals, no scripting languages, and no dynamic evaluation. Each rule is a flat list of conditions with AND semantics: every non-null condition must match for the rule to fire. This simplicity makes rules easy to reason about, easy to audit, and easy to test. If you can read a table, you can understand the reconciliation policy.
Rule Structure
Every selector rule consists of an identifier, an enabled flag, a priority, a set of optional conditions, and a plan template. Conditions that are not specified (null) are treated as wildcards -- they match any value. Only conditions that are explicitly set participate in matching.
| Field | Type | Description |
|---|---|---|
id | String | Unique rule identifier (e.g., "default-surf"). Used for logging, auditing, and as a tiebreaker when two rules share the same priority. |
enabled | Boolean | Whether this rule is active. Disabled rules are skipped during evaluation, allowing rules to be temporarily deactivated without deletion. |
priority | Integer | Evaluation order. Higher values are evaluated first. When two rules match, the one with the higher priority wins. If priorities are equal, the rule ID is used as an alphabetical tiebreaker. |
tenants | List<String>? | If set, this rule only applies to the listed tenant IDs. If null, the rule applies to all tenants. Use this to create tenant-specific reconciliation policies. |
entryPointTypes | List<String>? | If set, this rule only applies to the listed entry point types (e.g., "oid4vp", "oidc"). If null, the rule applies regardless of how the authentication was initiated. |
triggerTypes | List<String>? | If set, this rule only applies when the evaluation was triggered by one of the listed trigger types. Trigger types represent the reason the reconciliation engine was invoked. |
credentialTypes | List<String>? | If set, the Verifiable Presentation must contain at least one credential of a listed type. Used to differentiate reconciliation behavior based on the kind of credential presented. |
issuers | List<String>? | If set, at least one credential in the VP must be issued by a listed issuer. Used to apply different reconciliation policies based on credential provenance. |
knownHolderStates | List<String>? | If set, the holder key's database lookup result must be one of the listed states. This is the most commonly used condition, as it determines whether the user is new, returning, or expired. |
attributePredicates | Map? | If set, attribute values extracted from the credential must satisfy the specified predicates. Used for fine-grained matching based on claim values (e.g., only match credentials where assurance_level equals "high"). |
plan | PlanTemplate | The reconciliation plan to execute when this rule matches. Specifies the plan type and any additional parameters (provider ID, material profile ID, etc.). |
Evaluation Algorithm
The evaluation algorithm is deterministic and straightforward. Given an input context, the engine applies the following steps:
Step 1: Filter disabled rules
All rules with enabled: false are removed from consideration. This is a simple boolean check that runs before any condition evaluation.
Step 2: Match conditions
For each remaining rule, the engine checks every non-null condition against the input context. The matching semantics are:
- Null condition: matches anything. If a rule does not specify
tenants, it matches all tenants. If it does not specifycredentialTypes, it matches all credential types. And so on. - Non-null condition: must match. For list-type conditions (tenants, credentialTypes, issuers, etc.), the input value must be present in the list. For map-type conditions (attributePredicates), all predicates must be satisfied.
- AND semantics: ALL non-null conditions on a rule must match for the rule to be considered a candidate. If even one non-null condition fails, the entire rule is rejected.
This means a rule with no conditions set (all nulls) matches everything -- it is a universal catch-all. A rule with multiple conditions set is highly specific -- it only matches when all conditions are simultaneously satisfied.
Step 3: Sort candidates
All matching rules are sorted by priority in descending order (highest priority first). If two rules share the same priority, their IDs are compared alphabetically in ascending order as a tiebreaker. This ensures deterministic ordering even when priorities collide.
Step 4: Select the winner
The first rule in the sorted list is selected. Its plan template becomes the reconciliation plan for this evaluation. No further rules are examined.
Step 5: Handle no match
If no rules matched (the candidate list is empty), the engine returns null. The calling code interprets a null result as FailClosed -- the authentication is denied. This fail-safe behavior ensures that a misconfigured rule set (or an empty rule set) never accidentally permits access.
KnownHolderState Values
The knownHolderStates condition is the most frequently used discriminator in selector rules. It reflects the result of the database lookup that runs before reconciliation evaluation begins.
| State | Description |
|---|---|
MATCHED_HOLDER_KEY | The database contains an active, non-expired identity_link_binding for this holder key hash. The binding was found via a KEY-type identity_match record. This is the normal state for returning users. |
MATCHED_CLAIM_TUPLE | The database matched via a composite attribute hash (CLAIM_TUPLE-type identity_match) rather than the holder key directly. This occurs when the wallet key has changed but the user's attribute combination still maps to an existing binding. |
NOT_FOUND | No existing binding was found for any identifier associated with this holder. This is the state for first-time users who have never completed reconciliation. |
EXPIRED_BINDING | A binding exists for this holder key, but it has exceeded the configured inactivity threshold (binding-inactivity-threshold). The binding's last_used_at timestamp is too far in the past. The binding still exists in the database but is not considered valid for authentication. |
Portal's Default Rule Configuration
The portal ships with a minimal rule configuration that is sufficient for the proof-of-concept deployment:
sphereon:
app:
identity:
reconciliation:
rule-version: "2026-03-24"
selector-rules:
- id: "default-surf"
enabled: true
priority: 0
plan:
type: "RUN_IDV"
provider-id: "surf"
material-profile-id: "holder-only-v1"
This single rule means: "For any authentication attempt, regardless of tenant, entry point, credential type, or holder state, run identity verification via the SURF provider using the holder-only-v1 material profile."
Since no conditions are set (no tenants, no credentialTypes, no knownHolderStates, etc.), this rule matches everything. It sits at priority 0, which is irrelevant when there is only one rule. The rule-version field is a metadata tag for tracking configuration changes -- it does not affect evaluation.
This configuration is sufficient for the PoC because:
- There is only one reconciliation path (SURFconext via OIDC).
- The
UseExistingBindingfast path is handled by the engine's built-in binding lookup, which runs before selector rule evaluation. If a valid binding exists, reconciliation is skipped entirely before any rule is consulted. - There are no tenant-specific policies, no credential-type-specific behavior, and no assurance-level requirements.
Extended Rule Examples
In a production deployment, you would typically configure multiple rules with different priorities to handle various scenarios. Here is an example of a defense-in-depth rule set:
selector-rules:
- id: "known-holder-accept"
enabled: true
priority: 100
knownHolderStates: ["MATCHED_HOLDER_KEY"]
plan:
type: "USE_EXISTING_BINDING"
- id: "expired-step-up"
enabled: true
priority: 75
knownHolderStates: ["EXPIRED_BINDING"]
plan:
type: "STEP_UP"
provider-id: "surf"
material-profile-id: "holder-only-v1"
- id: "new-holder-idv"
enabled: true
priority: 50
knownHolderStates: ["NOT_FOUND"]
plan:
type: "RUN_IDV"
provider-id: "surf"
material-profile-id: "holder-only-v1"
- id: "fallback-deny"
enabled: true
priority: 0
plan:
type: "FAIL_CLOSED"
Priority ordering explained
The priority values determine evaluation order:
-
known-holder-accept(priority 100) -- Evaluated first. If the holder key is already known and the binding is active, accept immediately. This is the fast path for returning users. No external calls, no user interaction. -
expired-step-up(priority 75) -- Evaluated second. If the holder key is known but the binding has expired due to inactivity, require the user to re-verify. TheSTEP_UPplan creates a reconciliation session and redirects to SURFconext, similar to initial IDV. After successful re-verification, the existing binding is refreshed. -
new-holder-idv(priority 50) -- Evaluated third. If no binding exists at all, this is a new user. Run the full identity verification flow to create the initial binding. -
fallback-deny(priority 0) -- Evaluated last. This catch-all rule has no conditions, so it matches anything that was not caught by the higher-priority rules. Any unanticipated holder state (such asMATCHED_CLAIM_TUPLEwithout a specific rule) is denied. This defense-in-depth approach ensures no unanticipated case slips through.
Tenant-specific overrides
You can layer tenant-specific rules on top of the default set:
selector-rules:
- id: "tenant-a-skip"
enabled: true
priority: 200
tenants: ["tenant-a"]
plan:
type: "SKIP_RECONCILIATION"
# ... default rules at lower priorities ...
This adds a rule at priority 200 that matches only tenant-a and skips reconciliation entirely. Because it has the highest priority, it is evaluated before any other rule, and tenant-a authentication attempts never reach the lower-priority rules. All other tenants are unaffected and continue to use the default rule set.