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.