# Research: Credential Filtering API & Parsed Output

**Branch**: `003-credential-filtering-api` | **Date**: 2026-01-21

## Research Questions

### 1. How does the IDP map SDK constraints to presentation_definition?

**Decision**: The SDK's `constraints` HashMap in `CredentialPolicyRequest` maps directly to field constraints in the IDP's `input_descriptor.constraints.fields` array.

**Rationale**: The IDP's `policy-to-presentation.ts` service (lines 121-159) handles constraint mapping:
- `email_domain` → pattern filter: `.*@${domain}$`
- `age_minimum` → numeric filter: `{ minimum: value }`
- `email_verified` → boolean filter: `{ const: true }`
- Generic constraints → `{ const: value }` filter

**Alternatives Considered**:
- Custom constraint protocol: Rejected because the IDP already has well-defined constraint mapping
- SDK-side validation only: Rejected because constraints must reach the IDP for presentation_definition generation

### 2. What constraint types are already supported by the IDP?

**Decision**: The IDP supports these constraint types in `presentation_definition.constraints.fields.filter`:
- `const` - Exact value match
- `enum` - One of allowed values
- `minimum` / `maximum` - Numeric range
- `pattern` - Regex pattern matching
- `type` - Type validation (string, number, boolean)

**Rationale**: These are standard DIF Presentation Exchange v2 constraint types, visible in `credential-verifier.ts` (lines 350-445).

**SDK Mapping**:
| SDK Method | Constraint Key | IDP Filter Type |
|------------|---------------|-----------------|
| `.with_issuer_did(did)` | `iss` | `{ const: did }` |
| `.with_subject_did(did)` | `sub` | `{ const: did }` |
| `.with_email_domain(domain)` | `email_domain` | `{ pattern: ".*@{domain}$" }` |
| `.with_employer_did(did)` | `employer_did` | `{ const: did }` |
| `.with_minimum_level(n)` | `level_minimum` | `{ minimum: n }` |
| `.with_minimum_age(years)` | `age_minimum` | `{ minimum: years }` |
| `.with_claim_constraint(k, v)` | `{k}` | `{ const: v }` |

### 3. What is the existing type-state pattern in the SDK?

**Decision**: Follow the existing pattern using `PhantomData<State>` with `Incomplete` and `Complete` marker types.

**Rationale**: The existing policy builders (e.g., `EmploymentPolicy`, `PersonaPolicy`) use this pattern:
1. Initial state is `Policy<Incomplete>`
2. `.with_callback()` transitions to `Policy<Complete>`
3. Only `Policy<Complete>` implements `PolicyBuilder` trait
4. This provides compile-time guarantees that required fields are set

**Implementation Pattern** (from `employment.rs`):
```rust
pub struct EmploymentPolicy<State = Incomplete> {
    // fields...
    _marker: PhantomData<State>,
}

impl EmploymentPolicy<Incomplete> {
    // Builder methods that return Self
    pub fn with_employer_domain(mut self, domain: impl Into<String>) -> Self { ... }

    // Transition method that returns Complete state
    pub fn with_callback(self, url: impl Into<String>) -> EmploymentPolicy<Complete> { ... }
}

impl PolicyBuilder for EmploymentPolicy<Complete> {
    fn to_request(self) -> Result<CredentialPolicyRequest, LastIDError> { ... }
}
```

### 4. How should new constraint methods be added without breaking existing code?

**Decision**: Add new methods to the `<Incomplete>` state that return `Self`, following the existing fluent API pattern.

**Rationale**:
- Existing code continues to work unchanged
- New methods are additive (no changes to existing method signatures)
- Methods that return `Self` can be chained in any order before `.with_callback()`

**Implementation**:
```rust
impl EmploymentPolicy<Incomplete> {
    // EXISTING: Transitions to Complete
    pub fn with_callback(self, url: impl Into<String>) -> EmploymentPolicy<Complete> { ... }

    // NEW: Returns Self, stays Incomplete
    pub fn with_issuer_did(mut self, did: impl Into<String>) -> Self { ... }
    pub fn with_employer_did(mut self, did: impl Into<String>) -> Self { ... }
}
```

### 5. What device attestation fields are available in the IDP response?

**Decision**: The IDP returns structured device attestation in `device_attestation` and `device_security` fields.

**Rationale**: From `credential-verifier.ts` (lines 1329-1358):

**DeviceAttestation** fields:
- `has_secure_element: bool`
- `has_tee: bool`
- `integrity_status: String` ("pass", "fail", "unknown")
- `integrity_indicators: Vec<String>`
- `is_emulator: bool`
- `is_debugged: bool`
- `bootloader_locked: bool`
- `device_encrypted: bool`
- `hardware_fingerprint: Option<String>`
- `app_attest_supported: bool`
- `app_attest_key_id: Option<String>`
- `key_attestation_security_level: Option<String>`
- `model: Option<String>`
- `os_version: Option<String>`
- `collected_at: Option<i64>`

**DeviceSecurity** fields:
- `security_level: String` ("high", "medium", "low")
- `biometric_used: bool`
- `platform: String` ("ios", "android", "web")

### 6. How should multi-credential responses be structured?

**Decision**: Create a `VerifiedResponse` wrapper that contains a vector of `VerifiedCredential` with chain metadata.

**Rationale**: The IDP returns `credentials_presented` as an array of vct strings and `allPayloads` as an array of credential payloads. The SDK should:
1. Parse all credentials into individual `VerifiedCredential` instances
2. Provide `.credentials()` iterator
3. Provide `.credential_by_type(type_str)` lookup
4. Provide `.credential_chain()` for ordered access
5. Provide `.primary_credential()` for the leaf/target credential

**Implementation**:
```rust
pub struct VerifiedResponse {
    pub credentials: Vec<VerifiedCredential>,
    pub credential_chain: Vec<String>, // vct types in chain order
    pub device_attestation: Option<DeviceAttestation>,
    pub device_security: Option<DeviceSecurity>,
}

impl VerifiedResponse {
    pub fn credentials(&self) -> impl Iterator<Item = &VerifiedCredential> { ... }
    pub fn credential_by_type(&self, vct: &str) -> Option<&VerifiedCredential> { ... }
    pub fn credential_chain(&self) -> &[String] { ... }
    pub fn primary_credential(&self) -> Option<&VerifiedCredential> { ... }
}
```

### 7. How should typed credential parsing structs handle missing fields?

**Decision**: Use `Option<T>` for optional fields and return `Result<Self, LastIDError>` from `TryFrom`.

**Rationale**:
- Credentials have many optional fields (per SD-JWT selective disclosure)
- `TryFrom` allows descriptive error messages for required fields that are missing
- Aligns with Rust idioms and the SDK's error handling patterns

**Implementation Pattern**:
```rust
#[derive(Debug, Clone)]
pub struct ParsedPersona {
    pub display_name: Option<String>,
    pub email: Option<String>,
    pub phone: Option<String>,
    pub company: Option<String>,
    pub job_title: Option<String>,
    pub avatar_url: Option<String>,
    pub website: Option<String>,
    pub location: Option<String>,
    pub visibility: Option<String>,
}

impl TryFrom<&VerifiedCredential> for ParsedPersona {
    type Error = LastIDError;

    fn try_from(cred: &VerifiedCredential) -> Result<Self, Self::Error> {
        if cred.credential_type != "LastID.Persona" {
            return Err(LastIDError::credential_parse(format!(
                "Expected LastID.Persona, got {}",
                cred.credential_type
            )));
        }
        Ok(Self {
            display_name: cred.get_claim_str("display_name").map(String::from),
            // ... other fields
        })
    }
}
```

### 8. How should array claims be normalized?

**Decision**: Provide both singular accessors (`.email()`) that return first element, and plural accessors (`.emails()`) that return the full array.

**Rationale**:
- IDP normalizes arrays to first element for OIDC compatibility (line 1056-1058 in credential-verifier.ts)
- Some use cases need all values (e.g., multiple verified emails)
- SDK should match IDP behavior for singular accessors while providing escape hatch

**Implementation**:
```rust
impl VerifiedCredential {
    /// Get email as single value (first element if array)
    pub fn email(&self) -> Option<&str> {
        match self.claims.get("email") {
            Some(Value::String(s)) => Some(s.as_str()),
            Some(Value::Array(arr)) => arr.first().and_then(Value::as_str),
            _ => None,
        }
    }

    /// Get all emails if stored as array
    pub fn emails(&self) -> Vec<&str> {
        match self.claims.get("email") {
            Some(Value::String(s)) => vec![s.as_str()],
            Some(Value::Array(arr)) => arr.iter().filter_map(Value::as_str).collect(),
            _ => vec![],
        }
    }
}
```

## Decisions Summary

| Question | Decision |
|----------|----------|
| IDP constraint mapping | Use existing `constraints` HashMap; IDP handles filter generation |
| Supported constraint types | const, enum, minimum, maximum, pattern, type |
| Type-state pattern | Follow existing `PhantomData<State>` pattern |
| Backward compatibility | Add methods to `<Incomplete>` state returning `Self` |
| Device attestation | Create `DeviceAttestation` and `DeviceSecurity` structs |
| Multi-credential response | Create `VerifiedResponse` wrapper with iterators/lookup |
| Parsed credential errors | Use `TryFrom<&VerifiedCredential>` with `Result` |
| Array normalization | Singular returns first element; plural returns all |

## Dependencies

No new dependencies required. Implementation uses existing:
- `serde`, `serde_json` for serialization
- `chrono` for timestamps
- `thiserror` for error types
