Skip to main content

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.

FieldTypeDescription
idStringUnique rule identifier (e.g., "default-surf"). Used for logging, auditing, and as a tiebreaker when two rules share the same priority.
enabledBooleanWhether this rule is active. Disabled rules are skipped during evaluation, allowing rules to be temporarily deactivated without deletion.
priorityIntegerEvaluation 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.
tenantsList<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.
entryPointTypesList<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.
triggerTypesList<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.
credentialTypesList<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.
issuersList<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.
knownHolderStatesList<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.
attributePredicatesMap?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").
planPlanTemplateThe 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 specify credentialTypes, 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.

StateDescription
MATCHED_HOLDER_KEYThe 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_TUPLEThe 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_FOUNDNo 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_BINDINGA 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 UseExistingBinding fast 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:

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

  2. 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. The STEP_UP plan creates a reconciliation session and redirects to SURFconext, similar to initial IDV. After successful re-verification, the existing binding is refreshed.

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

  4. 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 as MATCHED_CLAIM_TUPLE without 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.