/ docs / plans / typing_improvement.md
typing_improvement.md
   1  # Typing Improvement Plan
   2  
   3  Pre-v0.1.0 type safety audit. Each section targets a specific weak type, shows the
   4  proposed change, and explains why it matters — especially in the context of the
   5  Radicle/Heartwood integration where stringly-typed boundaries caused repeated bugs.
   6  
   7  ---
   8  
   9  ## 1. `Attestation.rid` — bare `String` to `ResourceId` newtype
  10  
  11  **File:** `crates/auths-verifier/src/core.rs:336`
  12  
  13  ### Current
  14  
  15  ```rust
  16  pub struct Attestation {
  17      pub rid: String,
  18      // ...
  19  }
  20  ```
  21  
  22  ### Proposed
  23  
  24  ```rust
  25  // crates/auths-verifier/src/core.rs
  26  #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
  27  pub struct ResourceId(String);
  28  
  29  impl ResourceId {
  30      pub fn new(s: impl Into<String>) -> Self {
  31          Self(s.into())
  32      }
  33  
  34      pub fn as_str(&self) -> &str {
  35          &self.0
  36      }
  37  }
  38  
  39  impl std::ops::Deref for ResourceId {
  40      type Target = str;
  41      fn deref(&self) -> &str { &self.0 }
  42  }
  43  
  44  impl std::fmt::Display for ResourceId {
  45      fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
  46          f.write_str(&self.0)
  47      }
  48  }
  49  
  50  pub struct Attestation {
  51      pub rid: ResourceId,
  52      // ...
  53  }
  54  ```
  55  
  56  ### Why
  57  
  58  `rid` is used to link attestations to storage refs, to Radicle `RepoId`s, and as a
  59  lookup key across the org-member registry. Bare `String` allows accidental substitution
  60  of a DID, a Git ref, or any other string. A newtype makes the intent unambiguous and
  61  prevents cross-field confusion — particularly at the `RadAttestation <-> Attestation`
  62  conversion boundary where the Radicle `RepoId` must map cleanly to this field.
  63  
  64  ---
  65  
  66  ## 2. `Attestation.role` — bare `Option<String>` to `Option<Role>`
  67  
  68  **File:** `crates/auths-verifier/src/core.rs:367`
  69  
  70  ### Current
  71  
  72  ```rust
  73  pub struct Attestation {
  74      pub role: Option<String>,
  75      // ...
  76  }
  77  ```
  78  
  79  ### Proposed
  80  
  81  ```rust
  82  // crates/auths-verifier/src/core.rs
  83  #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
  84  #[non_exhaustive]
  85  pub enum Role {
  86      Admin,
  87      Member,
  88      Readonly,
  89  }
  90  
  91  pub struct Attestation {
  92      pub role: Option<Role>,
  93      // ...
  94  }
  95  ```
  96  
  97  Also update the duplicate `Role` enum in `crates/auths-sdk/src/workflows/org.rs:19-27`
  98  to re-export from `auths-verifier` instead of defining its own copy.
  99  
 100  ### Why
 101  
 102  The role field is compared at runtime in org-member verification
 103  (`org_member.rs` `MemberView.role`, SDK `AddMemberCommand`). Two separate `Role` enums
 104  exist today — one in auths-sdk and an implicit one via `String`. Unifying into a single
 105  source-of-truth enum in auths-verifier (the lowest-dependency crate) prevents typo-based
 106  mismatches like `"Admin"` vs `"admin"` and gives exhaustive match coverage across the
 107  Radicle bridge where role-based capability checks matter.
 108  
 109  ---
 110  
 111  ## 3. `Attestation.device_public_key` — `Vec<u8>` to `Ed25519PublicKey`
 112  
 113  **Files:**
 114  - `crates/auths-verifier/src/core.rs:343`
 115  - `crates/auths-core/src/signing.rs:96` (`ResolvedDid.public_key`)
 116  - `crates/auths-core/src/ports/network.rs:126` (`ResolvedIdentity.public_key`)
 117  
 118  ### Current
 119  
 120  ```rust
 121  // auths-verifier
 122  pub struct Attestation {
 123      #[serde(with = "hex::serde")]
 124      pub device_public_key: Vec<u8>,
 125      // ...
 126  }
 127  
 128  // auths-core
 129  pub struct ResolvedDid {
 130      pub public_key: Vec<u8>,
 131      // ...
 132  }
 133  
 134  pub struct ResolvedIdentity {
 135      pub public_key: Vec<u8>,
 136      // ...
 137  }
 138  ```
 139  
 140  ### Proposed
 141  
 142  ```rust
 143  // crates/auths-verifier/src/core.rs (or a shared types module)
 144  #[derive(Debug, Clone, PartialEq, Eq, Hash)]
 145  pub struct Ed25519PublicKey([u8; 32]);
 146  
 147  impl Ed25519PublicKey {
 148      pub fn from_bytes(bytes: [u8; 32]) -> Self {
 149          Self(bytes)
 150      }
 151  
 152      pub fn try_from_slice(bytes: &[u8]) -> Result<Self, Ed25519KeyError> {
 153          let arr: [u8; 32] = bytes
 154              .try_into()
 155              .map_err(|_| Ed25519KeyError::InvalidLength(bytes.len()))?;
 156          Ok(Self(arr))
 157      }
 158  
 159      pub fn as_bytes(&self) -> &[u8; 32] {
 160          &self.0
 161      }
 162  }
 163  
 164  // Serde: hex-encode for JSON, raw bytes for binary
 165  impl Serialize for Ed25519PublicKey {
 166      fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
 167          hex::serde::serialize(&self.0.to_vec(), serializer)
 168      }
 169  }
 170  // (Deserialize mirrors this — validate length on decode)
 171  
 172  #[derive(Debug, thiserror::Error)]
 173  pub enum Ed25519KeyError {
 174      #[error("expected 32 bytes, got {0}")]
 175      InvalidLength(usize),
 176  }
 177  ```
 178  
 179  Then update:
 180  ```rust
 181  pub struct Attestation {
 182      pub device_public_key: Ed25519PublicKey,
 183      // ...
 184  }
 185  
 186  pub struct ResolvedDid {
 187      pub public_key: Ed25519PublicKey,
 188      // ...
 189  }
 190  ```
 191  
 192  ### Why
 193  
 194  Ed25519 public keys are always exactly 32 bytes. Using `Vec<u8>` means every consumer
 195  must runtime-check length — and many don't. During the Radicle integration the bridge
 196  moves keys between `radicle_crypto::PublicKey` (which is `[u8; 32]`) and our `Vec<u8>`,
 197  introducing unnecessary `.try_into().unwrap()` calls. A fixed-size newtype eliminates
 198  an entire class of "wrong length" bugs at construction time rather than at use time.
 199  
 200  ---
 201  
 202  ## 4. `Attestation.identity_signature` / `device_signature` — `Vec<u8>` to `Ed25519Signature`
 203  
 204  **File:** `crates/auths-verifier/src/core.rs:346,349`
 205  
 206  ### Current
 207  
 208  ```rust
 209  pub struct Attestation {
 210      #[serde(with = "hex::serde")]
 211      pub identity_signature: Vec<u8>,
 212      #[serde(with = "hex::serde")]
 213      pub device_signature: Vec<u8>,
 214      // ...
 215  }
 216  ```
 217  
 218  ### Proposed
 219  
 220  ```rust
 221  // crates/auths-verifier/src/core.rs
 222  #[derive(Debug, Clone, PartialEq, Eq)]
 223  pub struct Ed25519Signature([u8; 64]);
 224  
 225  impl Ed25519Signature {
 226      pub fn from_bytes(bytes: [u8; 64]) -> Self {
 227          Self(bytes)
 228      }
 229  
 230      pub fn try_from_slice(bytes: &[u8]) -> Result<Self, SignatureLengthError> {
 231          let arr: [u8; 64] = bytes
 232              .try_into()
 233              .map_err(|_| SignatureLengthError(bytes.len()))?;
 234          Ok(Self(arr))
 235      }
 236  
 237      pub fn as_bytes(&self) -> &[u8; 64] {
 238          &self.0
 239      }
 240  
 241      pub fn empty() -> Self {
 242          Self([0u8; 64])
 243      }
 244  
 245      pub fn is_empty(&self) -> bool {
 246          self.0.iter().all(|&b| b == 0)
 247      }
 248  }
 249  // Serialize/Deserialize via hex, same pattern as Ed25519PublicKey
 250  
 251  pub struct Attestation {
 252      pub identity_signature: Ed25519Signature,
 253      pub device_signature: Ed25519Signature,
 254      // ...
 255  }
 256  ```
 257  
 258  ### Why
 259  
 260  Ed25519 signatures are always 64 bytes. The `identity_signature` field uses
 261  `skip_serializing_if = "Vec::is_empty"` to handle the intermediate state where the
 262  identity hasn't signed yet — `Ed25519Signature::empty()` / `is_empty()` preserves this
 263  while making the invariant explicit. This prevents the class of bug where a truncated
 264  or extra-long signature passes type checks but fails at verification time. The Radicle
 265  `RadAttestation` already has `device_signature: Vec<u8>` and `identity_signature: Vec<u8>`
 266  fields — converting them both to `Ed25519Signature` at the bridge boundary catches
 267  corruption early.
 268  
 269  ---
 270  
 271  ## 5. `Seal.seal_type` — bare `String` to `SealType` enum
 272  
 273  **File:** `crates/auths-id/src/keri/seal.rs:22`
 274  
 275  ### Current
 276  
 277  ```rust
 278  pub struct Seal {
 279      pub d: Said,
 280      #[serde(rename = "type")]
 281      pub seal_type: String,
 282  }
 283  
 284  impl Seal {
 285      pub fn device_attestation(said: Said) -> Self { /* seal_type: "device-attestation" */ }
 286      pub fn revocation(said: Said) -> Self { /* seal_type: "revocation" */ }
 287      pub fn delegation(said: Said) -> Self { /* seal_type: "delegation" */ }
 288  }
 289  ```
 290  
 291  ### Proposed
 292  
 293  ```rust
 294  #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
 295  #[serde(rename_all = "kebab-case")]
 296  #[non_exhaustive]
 297  pub enum SealType {
 298      DeviceAttestation,
 299      Revocation,
 300      Delegation,
 301  }
 302  
 303  pub struct Seal {
 304      pub d: Said,
 305      #[serde(rename = "type")]
 306      pub seal_type: SealType,
 307  }
 308  ```
 309  
 310  ### Why
 311  
 312  The factory methods already prove the set is closed. A bare `String` lets callers
 313  construct a `Seal` with an arbitrary `seal_type` (e.g., `"revocaton"` typo), which
 314  silently passes serialization but breaks downstream consumers. An enum makes invalid
 315  seal types a compile error. `#[non_exhaustive]` preserves forward compatibility for
 316  new seal types.
 317  
 318  ---
 319  
 320  ## 6. `StorageLayoutConfig` — `String` fields to `GitRef` / `BlobName` newtypes
 321  
 322  **File:** `crates/auths-id/src/storage/layout.rs:82-98`
 323  
 324  ### Current
 325  
 326  ```rust
 327  pub struct StorageLayoutConfig {
 328      pub identity_ref: String,
 329      pub device_attestation_prefix: String,
 330      pub attestation_blob_name: String,
 331      pub identity_blob_name: String,
 332  }
 333  ```
 334  
 335  ### Proposed
 336  
 337  ```rust
 338  // crates/auths-id/src/storage/layout.rs
 339  #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
 340  pub struct GitRef(String);
 341  
 342  impl GitRef {
 343      pub fn new(s: impl Into<String>) -> Self { Self(s.into()) }
 344      pub fn as_str(&self) -> &str { &self.0 }
 345      pub fn join(&self, segment: &str) -> Self {
 346          Self(format!("{}/{}", self.0, segment))
 347      }
 348  }
 349  
 350  impl std::fmt::Display for GitRef {
 351      fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
 352          f.write_str(&self.0)
 353      }
 354  }
 355  
 356  #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
 357  pub struct BlobName(String);
 358  
 359  impl BlobName {
 360      pub fn new(s: impl Into<String>) -> Self { Self(s.into()) }
 361      pub fn as_str(&self) -> &str { &self.0 }
 362  }
 363  
 364  pub struct StorageLayoutConfig {
 365      pub identity_ref: GitRef,
 366      pub device_attestation_prefix: GitRef,
 367      pub attestation_blob_name: BlobName,
 368      pub identity_blob_name: BlobName,
 369  }
 370  ```
 371  
 372  ### Why
 373  
 374  During the RIP-X ref path reconciliation (`fn-3.2`), the biggest source of confusion
 375  was whether a string contained a full ref (`refs/keri/kel`), a prefix
 376  (`refs/auths/keys`), or a blob name (`attestation.json`). Three different semantic
 377  categories were all `String`. The `GitRef` and `BlobName` newtypes make it impossible
 378  to accidentally pass an `identity_blob_name` where a `device_attestation_prefix` is
 379  expected. The `GitRef::join()` method also standardizes ref construction without ad-hoc
 380  `format!` calls scattered across the storage layer.
 381  
 382  ---
 383  
 384  ## 7. `Seal.d` / KERI event sequence — `String` to `u64`
 385  
 386  **File:** `crates/auths-id/src/keri/event.rs:63,100,140`
 387  
 388  ### Current
 389  
 390  ```rust
 391  pub struct IcpEvent {
 392      pub s: String,  // sequence number as hex string
 393      // ...
 394  }
 395  
 396  pub struct RotEvent {
 397      pub s: String,
 398      // ...
 399  }
 400  
 401  pub struct IxnEvent {
 402      pub s: String,
 403      // ...
 404  }
 405  ```
 406  
 407  The shared method `Event::sequence()` then parses `s` as hex `u64` each time, and a
 408  `SequenceParseError` exists just for this conversion.
 409  
 410  ### Proposed
 411  
 412  ```rust
 413  // crates/auths-id/src/keri/event.rs
 414  #[derive(Debug, Clone, PartialEq, Eq)]
 415  pub struct KeriSequence(u64);
 416  
 417  impl KeriSequence {
 418      pub fn new(n: u64) -> Self { Self(n) }
 419      pub fn value(&self) -> u64 { self.0 }
 420  }
 421  
 422  impl Serialize for KeriSequence {
 423      fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
 424          serializer.serialize_str(&format!("{:x}", self.0))
 425      }
 426  }
 427  
 428  impl<'de> Deserialize<'de> for KeriSequence {
 429      fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
 430          let s = String::deserialize(deserializer)?;
 431          let n = u64::from_str_radix(&s, 16)
 432              .map_err(serde::de::Error::custom)?;
 433          Ok(Self(n))
 434      }
 435  }
 436  
 437  pub struct IcpEvent {
 438      pub s: KeriSequence,
 439      // ...
 440  }
 441  ```
 442  
 443  ### Why
 444  
 445  Every caller of `Event::sequence()` must handle a `SequenceParseError` that can never
 446  happen if the sequence was validated at deserialization. Moving validation into serde
 447  (parse-don't-validate pattern) eliminates `SequenceParseError` entirely and makes
 448  `Event::sequence()` infallible. In the Radicle bridge, `min_kel_seq: Option<u64>` in
 449  `VerifyRequest` is compared against the identity's sequence — this comparison currently
 450  requires parsing the string first.
 451  
 452  ---
 453  
 454  ## 8. SDK result types — bare `String` DIDs to `IdentityDID` / `DeviceDID`
 455  
 456  **File:** `crates/auths-sdk/src/result.rs` (entire file)
 457  
 458  ### Current
 459  
 460  ```rust
 461  pub struct SetupResult {
 462      pub identity_did: String,
 463      pub device_did: String,
 464      // ...
 465  }
 466  
 467  pub struct CiSetupResult {
 468      pub identity_did: String,
 469      pub device_did: String,
 470      // ...
 471  }
 472  
 473  pub struct DeviceLinkResult {
 474      pub device_did: String,
 475      pub attestation_id: String,
 476  }
 477  
 478  pub struct RotationResult {
 479      pub controller_did: String,
 480      // ...
 481  }
 482  // ... 7 more structs with String DIDs
 483  ```
 484  
 485  ### Proposed
 486  
 487  ```rust
 488  use auths_verifier::types::{IdentityDID, DeviceDID};
 489  use auths_verifier::core::ResourceId;
 490  
 491  pub struct SetupResult {
 492      pub identity_did: IdentityDID,
 493      pub device_did: DeviceDID,
 494      pub key_alias: KeyAlias,
 495      pub platform_claim: Option<PlatformClaimResult>,
 496      pub git_signing_configured: bool,
 497      pub registered: Option<RegistrationOutcome>,
 498  }
 499  
 500  pub struct CiSetupResult {
 501      pub identity_did: IdentityDID,
 502      pub device_did: DeviceDID,
 503      pub env_block: Vec<String>,
 504  }
 505  
 506  pub struct DeviceLinkResult {
 507      pub device_did: DeviceDID,
 508      pub attestation_id: ResourceId,
 509  }
 510  
 511  pub struct RotationResult {
 512      pub controller_did: IdentityDID,
 513      pub new_key_fingerprint: String,
 514      pub previous_key_fingerprint: String,
 515  }
 516  
 517  pub struct DeviceExtensionResult {
 518      pub device_did: DeviceDID,
 519      pub new_expires_at: chrono::DateTime<chrono::Utc>,
 520  }
 521  
 522  pub struct AgentSetupResult {
 523      pub agent_did: IdentityDID,
 524      pub parent_did: IdentityDID,
 525      pub capabilities: Vec<Capability>,
 526  }
 527  ```
 528  
 529  ### Why
 530  
 531  `IdentityDID` and `DeviceDID` already exist in auths-verifier with validation and
 532  `Display` impls. The SDK is the primary API surface developers interact with — returning
 533  bare `String` means every consumer must re-validate or blindly trust. Typed DIDs
 534  eliminate an entire category of "passed the wrong DID type" bugs at the SDK boundary.
 535  This directly solves the pain point from `fn-5.5` ("Move away from stringly typed DIDs
 536  to structured objects"). Also replaces `Vec<String>` capabilities with
 537  `Vec<Capability>` — the `Capability` newtype already exists in auths-verifier with
 538  validation.
 539  
 540  ---
 541  
 542  ## 9. SDK config types — `Vec<String>` capabilities to `Vec<Capability>`
 543  
 544  **Files:**
 545  - `crates/auths-sdk/src/types.rs:375` (`AgentSetupConfig.capabilities`)
 546  - `crates/auths-sdk/src/types.rs:586` (`DeviceLinkConfig.capabilities`)
 547  - `crates/auths-sdk/src/pairing.rs:75,132` (`PairingSessionParams.capabilities`, etc.)
 548  - `crates/auths-sdk/src/workflows/org.rs:185,207` (`AddMemberCommand.capabilities`)
 549  
 550  ### Current
 551  
 552  ```rust
 553  pub struct AgentSetupConfig {
 554      pub capabilities: Vec<String>,
 555      // ...
 556  }
 557  
 558  pub struct DeviceLinkConfig {
 559      pub capabilities: Vec<String>,
 560      // ...
 561  }
 562  ```
 563  
 564  ### Proposed
 565  
 566  ```rust
 567  use auths_verifier::core::Capability;
 568  
 569  pub struct AgentSetupConfig {
 570      pub capabilities: Vec<Capability>,
 571      // ...
 572  }
 573  
 574  pub struct DeviceLinkConfig {
 575      pub capabilities: Vec<Capability>,
 576      // ...
 577  }
 578  ```
 579  
 580  ### Why
 581  
 582  The `Capability` newtype in auths-verifier already validates: non-empty, max 64 chars,
 583  only `[a-zA-Z0-9:_-]`, no reserved `auths:` prefix. Every `Vec<String>` capability
 584  field today pushes this validation to downstream code (or skips it entirely). Using
 585  `Capability` at the config boundary means invalid capabilities are rejected at
 586  construction time — before they ever reach the signing or attestation layer. The
 587  Heartwood integration specifically needs this because capabilities flow through the
 588  `RadicleAuthsBridge::verify_signer()` path where a malformed string would silently
 589  fail policy matching.
 590  
 591  ---
 592  
 593  ## 10. `ResolvedDid.did` — bare `String` to enum dispatch
 594  
 595  **File:** `crates/auths-core/src/signing.rs:92-99`
 596  
 597  ### Current
 598  
 599  ```rust
 600  pub struct ResolvedDid {
 601      pub did: String,
 602      pub public_key: Vec<u8>,
 603      pub method: DidMethod,
 604  }
 605  ```
 606  
 607  The `did` field and `method` field are independently set — nothing enforces that a
 608  `did:keri:...` string has `method: DidMethod::Keri { .. }`, or that a `did:key:...`
 609  string has `method: DidMethod::Key`.
 610  
 611  ### Proposed
 612  
 613  ```rust
 614  pub enum ResolvedDid {
 615      Key {
 616          did: DeviceDID,
 617          public_key: Ed25519PublicKey,
 618      },
 619      Keri {
 620          did: KeriDid,
 621          public_key: Ed25519PublicKey,
 622          sequence: u64,
 623          can_rotate: bool,
 624      },
 625  }
 626  
 627  impl ResolvedDid {
 628      pub fn public_key(&self) -> &Ed25519PublicKey {
 629          match self {
 630              Self::Key { public_key, .. } | Self::Keri { public_key, .. } => public_key,
 631          }
 632      }
 633  
 634      pub fn did_string(&self) -> &str {
 635          match self {
 636              Self::Key { did, .. } => did.as_str(),
 637              Self::Keri { did, .. } => did.as_str(),
 638          }
 639      }
 640  }
 641  ```
 642  
 643  ### Why
 644  
 645  A struct with parallel `did: String` + `method: DidMethod` fields is a classic
 646  "boolean blindness" anti-pattern — the two fields can be independently set to
 647  contradictory values. An enum makes the DID type and its metadata structurally
 648  inseparable. This directly mirrors the Radicle `Did` enum (`Did::Key(PublicKey)` vs
 649  implicit `Did::Keri`) and eliminates the mismatch bugs reported during the bridge
 650  integration. The `DidResolver` trait would return this enum, making callers use
 651  `match` to handle both DID methods explicitly.
 652  
 653  ---
 654  
 655  ## 11. `StoredIdentityData.controller_did` — bare `String` to `IdentityDID`
 656  
 657  **File:** `crates/auths-id/src/storage/identity.rs:22`
 658  
 659  ### Current
 660  
 661  ```rust
 662  struct StoredIdentityData {
 663      version: u32,
 664      controller_did: String,
 665      metadata: Option<serde_json::Value>,
 666  }
 667  ```
 668  
 669  ### Proposed
 670  
 671  ```rust
 672  use auths_verifier::types::IdentityDID;
 673  
 674  struct StoredIdentityData {
 675      version: u32,
 676      controller_did: IdentityDID,
 677      metadata: Option<serde_json::Value>,
 678  }
 679  ```
 680  
 681  ### Why
 682  
 683  `StoredIdentityData` is serialized to/from JSON in Git blobs. Using `IdentityDID`
 684  means deserialization rejects malformed controller DIDs at the storage boundary instead
 685  of propagating them through the identity lifecycle. Since `IdentityDID` already
 686  implements `Serialize`/`Deserialize`, this is a low-risk change.
 687  
 688  ---
 689  
 690  ## 12. `MemberInvalidReason` — bare `String` fields to typed DIDs
 691  
 692  **File:** `crates/auths-id/src/storage/registry/org_member.rs:120-146`
 693  
 694  ### Current
 695  
 696  ```rust
 697  pub enum MemberInvalidReason {
 698      JsonParseError(String),
 699      SubjectMismatch {
 700          filename_did: String,
 701          attestation_subject: String,
 702      },
 703      IssuerMismatch {
 704          expected_issuer: String,
 705          actual_issuer: String,
 706      },
 707      Other(String),
 708  }
 709  ```
 710  
 711  ### Proposed
 712  
 713  ```rust
 714  use auths_verifier::types::{DeviceDID, IdentityDID};
 715  
 716  pub enum MemberInvalidReason {
 717      JsonParseError(String),
 718      SubjectMismatch {
 719          filename_did: DeviceDID,
 720          attestation_subject: DeviceDID,
 721      },
 722      IssuerMismatch {
 723          expected_issuer: IdentityDID,
 724          actual_issuer: IdentityDID,
 725      },
 726      Other(String),
 727  }
 728  ```
 729  
 730  ### Why
 731  
 732  Error messages constructed from these fields currently display raw strings — but the
 733  fields semantically _are_ DIDs. Typed fields mean Display formatting is consistent
 734  (always `did:key:z6Mk...` or `did:keri:E...`) and prevents accidentally swapping an
 735  issuer DID into a subject position. This also improves error diagnostics in the Radicle
 736  bridge where member validation failures need to clearly identify which identity was
 737  expected vs. found.
 738  
 739  ---
 740  
 741  ## 13. `MemberView` — bare `String` fields to typed equivalents
 742  
 743  **File:** `crates/auths-id/src/storage/registry/org_member.rs:176-192`
 744  
 745  ### Current
 746  
 747  ```rust
 748  pub struct MemberView {
 749      pub did: DeviceDID,
 750      pub status: MemberStatus,
 751      pub role: Option<String>,
 752      pub capabilities: Vec<String>,
 753      pub issuer: String,
 754      pub rid: String,
 755      // ...
 756  }
 757  ```
 758  
 759  ### Proposed
 760  
 761  ```rust
 762  pub struct MemberView {
 763      pub did: DeviceDID,
 764      pub status: MemberStatus,
 765      pub role: Option<Role>,
 766      pub capabilities: Vec<Capability>,
 767      pub issuer: IdentityDID,
 768      pub rid: ResourceId,
 769      // ...
 770  }
 771  ```
 772  
 773  ### Why
 774  
 775  `MemberView` is the primary query result for the org-member registry and is rendered
 776  directly in CLI output and API responses. Every field that's currently a `String` has a
 777  well-defined semantic type (`Role`, `Capability`, `IdentityDID`, `ResourceId`) that's
 778  already defined elsewhere. Using them here ensures the view layer can never display
 779  malformed data.
 780  
 781  ---
 782  
 783  ## 14. `BridgeError` — bare `String` context to structured variants
 784  
 785  **File:** `crates/auths-radicle/src/bridge.rs:140-168`
 786  
 787  ### Current
 788  
 789  ```rust
 790  pub enum BridgeError {
 791      IdentityLoad(String),
 792      AttestationLoad(String),
 793      IdentityCorrupt(String),
 794      PolicyEvaluation(String),
 795      InvalidDeviceKey(String),
 796      Repository(String),
 797  }
 798  ```
 799  
 800  ### Proposed
 801  
 802  ```rust
 803  use auths_verifier::types::{DeviceDID, IdentityDID};
 804  
 805  #[derive(Debug, thiserror::Error)]
 806  #[non_exhaustive]
 807  pub enum BridgeError {
 808      #[error("failed to load identity {did}: {reason}")]
 809      IdentityLoad { did: IdentityDID, reason: String },
 810  
 811      #[error("failed to load attestation for device {device_did}: {reason}")]
 812      AttestationLoad { device_did: DeviceDID, reason: String },
 813  
 814      #[error("identity {did} has corrupt KEL: {reason}")]
 815      IdentityCorrupt { did: IdentityDID, reason: String },
 816  
 817      #[error("policy evaluation failed for {did}: {reason}")]
 818      PolicyEvaluation { did: IdentityDID, reason: String },
 819  
 820      #[error("invalid device key: {reason}")]
 821      InvalidDeviceKey { reason: String },
 822  
 823      #[error("repository access error: {reason}")]
 824      Repository { reason: String },
 825  }
 826  ```
 827  
 828  ### Why
 829  
 830  Current `BridgeError` variants carry a single `String` that mixes the "what" (which
 831  identity/device) with the "why" (what went wrong). Structured fields let the Heartwood
 832  integration layer extract the DID for remediation (e.g., "fetch identity repo for
 833  `did:keri:EABC...`") without parsing error messages. This was explicitly called out in
 834  `fn-1.4` as needed to distinguish `IdentityLoad` (missing repo, actionable) from
 835  `IdentityCorrupt` (corrupt data, investigate).
 836  
 837  ---
 838  
 839  ## 15. `WitnessConfig.witness_urls` — `Vec<String>` to `Vec<url::Url>`
 840  
 841  **File:** `crates/auths-id/src/witness_config.rs:12`
 842  
 843  ### Current
 844  
 845  ```rust
 846  pub struct WitnessConfig {
 847      pub witness_urls: Vec<String>,
 848      pub threshold: usize,
 849      pub timeout_ms: u64,
 850      pub policy: WitnessPolicy,
 851  }
 852  ```
 853  
 854  ### Proposed
 855  
 856  ```rust
 857  use url::Url;
 858  
 859  pub struct WitnessConfig {
 860      pub witness_urls: Vec<Url>,
 861      pub threshold: usize,
 862      pub timeout_ms: u64,
 863      pub policy: WitnessPolicy,
 864  }
 865  ```
 866  
 867  ### Why
 868  
 869  Witness URLs are used to make HTTP requests. A malformed URL will fail at request time
 870  with an opaque error. Using `url::Url` (which is already a dependency via
 871  `reqwest`) validates at construction. This also lets witness URL comparison be
 872  correct — `Url` normalizes trailing slashes, scheme casing, etc.
 873  
 874  ---
 875  
 876  ## 16. `ReceiptVerificationResult` — bare `String` fields to typed
 877  
 878  **File:** `crates/auths-id/src/policy/mod.rs:245-255`
 879  
 880  ### Current
 881  
 882  ```rust
 883  pub enum ReceiptVerificationResult {
 884      Valid,
 885      InsufficientReceipts { required: usize, got: usize },
 886      Duplicity { event_a: String, event_b: String },
 887      InvalidSignature { witness_did: String },
 888  }
 889  ```
 890  
 891  ### Proposed
 892  
 893  ```rust
 894  pub enum ReceiptVerificationResult {
 895      Valid,
 896      InsufficientReceipts { required: usize, got: usize },
 897      Duplicity { event_a: Said, event_b: Said },
 898      InvalidSignature { witness_did: DeviceDID },
 899  }
 900  ```
 901  
 902  ### Why
 903  
 904  `event_a` and `event_b` are KERI event SAIDs (Self-Addressing Identifiers) — the `Said`
 905  newtype already exists and is used everywhere else in the KERI layer. `witness_did` is a
 906  DID that identifies a witness node. Using typed fields ensures these values are
 907  structurally valid when constructing the result, rather than trusting arbitrary strings
 908  from the witness protocol.
 909  
 910  ---
 911  
 912  ## 17. `AgentIdentityBundle.agent_did` — bare `String` to `IdentityDID`
 913  
 914  **File:** `crates/auths-id/src/agent_identity.rs:85`
 915  
 916  ### Current
 917  
 918  ```rust
 919  pub struct AgentIdentityBundle {
 920      pub agent_did: String,
 921      pub key_alias: KeyAlias,
 922      pub attestation: Attestation,
 923      pub repo_path: Option<PathBuf>,
 924  }
 925  ```
 926  
 927  ### Proposed
 928  
 929  ```rust
 930  pub struct AgentIdentityBundle {
 931      pub agent_did: IdentityDID,
 932      pub key_alias: KeyAlias,
 933      pub attestation: Attestation,
 934      pub repo_path: Option<PathBuf>,
 935  }
 936  ```
 937  
 938  ### Why
 939  
 940  Same rationale as the SDK result types. `agent_did` is constructed from a KERI prefix
 941  and must be a valid `did:keri:...` — using `IdentityDID` enforces this. The
 942  `AgentProvisioningConfig.agent_name` field remains a `String` since it's a free-form
 943  human label, not a DID.
 944  
 945  ---
 946  
 947  ## 18. Pairing types — base64url strings to typed wrappers
 948  
 949  **File:** `crates/auths-core/src/pairing/types.rs:28-90`
 950  
 951  ### Current
 952  
 953  ```rust
 954  pub struct CreateSessionRequest {
 955      pub ephemeral_pubkey: String,  // base64url
 956      // ...
 957  }
 958  
 959  pub struct SubmitResponseRequest {
 960      pub device_x25519_pubkey: String,    // base64url
 961      pub device_signing_pubkey: String,   // base64url
 962      pub device_did: String,
 963      pub signature: String,               // base64url
 964  }
 965  ```
 966  
 967  ### Proposed
 968  
 969  ```rust
 970  // crates/auths-core/src/pairing/types.rs
 971  #[derive(Debug, Clone, Serialize, Deserialize)]
 972  pub struct Base64UrlEncoded(String);
 973  
 974  impl Base64UrlEncoded {
 975      pub fn encode(bytes: &[u8]) -> Self {
 976          use base64::engine::general_purpose::URL_SAFE_NO_PAD;
 977          use base64::Engine;
 978          Self(URL_SAFE_NO_PAD.encode(bytes))
 979      }
 980  
 981      pub fn decode(&self) -> Result<Vec<u8>, base64::DecodeError> {
 982          use base64::engine::general_purpose::URL_SAFE_NO_PAD;
 983          use base64::Engine;
 984          URL_SAFE_NO_PAD.decode(&self.0)
 985      }
 986  
 987      pub fn as_str(&self) -> &str { &self.0 }
 988  }
 989  
 990  pub struct CreateSessionRequest {
 991      pub ephemeral_pubkey: Base64UrlEncoded,
 992      // ...
 993  }
 994  
 995  pub struct SubmitResponseRequest {
 996      pub device_x25519_pubkey: Base64UrlEncoded,
 997      pub device_signing_pubkey: Base64UrlEncoded,
 998      pub device_did: DeviceDID,
 999      pub signature: Base64UrlEncoded,
1000  }
1001  ```
1002  
1003  ### Why
1004  
1005  The pairing protocol is an HTTP API surface. These fields are base64url-encoded
1006  cryptographic material but typed as `String` — making it possible to pass hex-encoded
1007  or raw bytes. A `Base64UrlEncoded` wrapper ensures encoding consistency at the boundary.
1008  The `device_did` field also gets typed as `DeviceDID` since it's always a `did:key:...`.
1009  
1010  ---
1011  
1012  ## 19. `OrgMemberEntry.org` — bare `String` to `IdentityDID`
1013  
1014  **File:** `crates/auths-id/src/storage/registry/org_member.rs:150`
1015  
1016  ### Current
1017  
1018  ```rust
1019  pub struct OrgMemberEntry {
1020      pub org: String,
1021      pub did: DeviceDID,
1022      pub filename: String,
1023      pub attestation: Result<Attestation, MemberInvalidReason>,
1024  }
1025  ```
1026  
1027  ### Proposed
1028  
1029  ```rust
1030  pub struct OrgMemberEntry {
1031      pub org: IdentityDID,
1032      pub did: DeviceDID,
1033      pub filename: GitRef,
1034      pub attestation: Result<Attestation, MemberInvalidReason>,
1035  }
1036  ```
1037  
1038  ### Why
1039  
1040  `org` is the organization's identity DID, not an arbitrary string. `filename` is a Git
1041  ref path to the member's attestation blob. Both have well-defined types already in scope.
1042  
1043  ---
1044  
1045  ## 20. `VerifyResult` reason fields — structured context
1046  
1047  **File:** `crates/auths-radicle/src/bridge.rs:39-84`
1048  
1049  ### Current
1050  
1051  ```rust
1052  #[non_exhaustive]
1053  pub enum VerifyResult {
1054      Verified { reason: String },
1055      Rejected { reason: String },
1056      Warn { reason: String },
1057      Quarantine { reason: String, identity_repo_rid: Option<RepoId> },
1058  }
1059  ```
1060  
1061  ### Proposed
1062  
1063  ```rust
1064  #[non_exhaustive]
1065  pub enum VerifyResult {
1066      Verified { reason: VerifyReason },
1067      Rejected { reason: RejectReason },
1068      Warn { reason: WarnReason },
1069      Quarantine { reason: QuarantineReason, identity_repo_rid: Option<RepoId> },
1070  }
1071  
1072  #[derive(Debug, Clone)]
1073  #[non_exhaustive]
1074  pub enum VerifyReason {
1075      DeviceAttested,
1076      LegacyDidKey,
1077  }
1078  
1079  #[derive(Debug, Clone)]
1080  #[non_exhaustive]
1081  pub enum RejectReason {
1082      Revoked,
1083      Expired,
1084      NoAttestation,
1085      PolicyDenied { capability: String },
1086      KelCorrupt,
1087  }
1088  
1089  #[derive(Debug, Clone)]
1090  #[non_exhaustive]
1091  pub enum WarnReason {
1092      ObserveModeRejection(RejectReason),
1093  }
1094  
1095  #[derive(Debug, Clone)]
1096  #[non_exhaustive]
1097  pub enum QuarantineReason {
1098      StaleNode,
1099      MissingIdentityRepo,
1100      InsufficientKelSequence { have: u64, need: u64 },
1101  }
1102  ```
1103  
1104  ### Why
1105  
1106  The current `reason: String` fields are constructed ad-hoc in the verification pipeline
1107  and consumed as display text. But the Heartwood integration needs to _act_ on these
1108  reasons — e.g., if `Quarantine` is due to a stale node, Heartwood should trigger a
1109  fetch; if due to KEL corruption, it should log and skip. Structured reason enums enable
1110  `match`-based dispatch instead of string parsing. All enums are `#[non_exhaustive]` so
1111  new reasons can be added without breaking downstream.
1112  
1113  ---
1114  
1115  ## Summary
1116  
1117  | # | Type Change | Crate | Impact |
1118  |---|------------|-------|--------|
1119  | 1 | `Attestation.rid` -> `ResourceId` | auths-verifier | Medium (serialization boundary) |
1120  | 2 | `Attestation.role` -> `Role` enum | auths-verifier | Low (additive) |
1121  | 3 | `device_public_key` -> `Ed25519PublicKey` | auths-verifier, auths-core | High (pervasive) |
1122  | 4 | signatures -> `Ed25519Signature` | auths-verifier | High (pervasive) |
1123  | 5 | `Seal.seal_type` -> `SealType` enum | auths-id | Low (internal) |
1124  | 6 | `StorageLayoutConfig` -> `GitRef`/`BlobName` | auths-id | Medium (storage layer) |
1125  | 7 | Event `s` field -> `KeriSequence` | auths-id | Medium (KERI layer) |
1126  | 8 | SDK result DIDs -> `IdentityDID`/`DeviceDID` | auths-sdk | High (public API) |
1127  | 9 | `Vec<String>` capabilities -> `Vec<Capability>` | auths-sdk | Medium (public API) |
1128  | 10 | `ResolvedDid` struct -> enum | auths-core | High (trait boundary) |
1129  | 11 | `StoredIdentityData.controller_did` -> `IdentityDID` | auths-id | Low (internal) |
1130  | 12 | `MemberInvalidReason` fields -> typed DIDs | auths-id | Low (error display) |
1131  | 13 | `MemberView` fields -> typed | auths-id | Low (query results) |
1132  | 14 | `BridgeError` -> structured variants | auths-radicle | Medium (error handling) |
1133  | 15 | `witness_urls` -> `Vec<Url>` | auths-id | Low (config) |
1134  | 16 | `ReceiptVerificationResult` -> typed fields | auths-id | Low (policy) |
1135  | 17 | `AgentIdentityBundle.agent_did` -> `IdentityDID` | auths-id | Low (internal) |
1136  | 18 | Pairing strings -> `Base64UrlEncoded` + `DeviceDID` | auths-core | Medium (API boundary) |
1137  | 19 | `OrgMemberEntry` fields -> typed | auths-id | Low (internal) |
1138  | 20 | `VerifyResult` reason -> enums | auths-radicle | Medium (bridge contract) |
1139  
1140  ### Recommended execution order
1141  
1142  1. **Foundation types first** (1-4): `ResourceId`, `Role`, `Ed25519PublicKey`, `Ed25519Signature` in auths-verifier — everything else depends on these.
1143  2. **Core internal types** (5-7, 15-16): `SealType`, `GitRef`/`BlobName`, `KeriSequence`, `Url`, receipt fields — contained within auths-id, no cross-crate ripple.
1144  3. **Bridge types** (14, 20): Structured `BridgeError` and `VerifyResult` reasons — directly unblocks Heartwood integration quality.
1145  4. **SDK public API** (8-10): Result DIDs, capabilities, `ResolvedDid` enum — highest-visibility changes, do last to minimize churn while foundation stabilizes.
1146  5. **Remaining internal cleanup** (11-13, 17-19): Low-risk, low-impact — can be done opportunistically.