auths_id/storage/registry/backend.rs
1//! Registry backend trait and error types.
2//!
3//! # Architecture: Cache/Index, Not Source of Truth
4//!
5//! The registry is an **indexed materialized view** for performance:
6//!
7//! - **Identity KEL (Key Event Log)** = source of truth
8//! - Events in `v1/identities/<shard>/<prefix>/events/` are canonical
9//! - All other state is derived from the KEL
10//!
11//! - **Registry** = cached index for O(1) lookups
12//! - `state.json`, `tip.json` are performance caches
13//! - Can be rebuilt from KEL at any time
14//! - NOT authoritative for trust or authorization decisions
15//!
16//! - **Backend validates structure, not trust**
17//! - Backends may validate JSON structure, SAID integrity
18//! - Backends may NOT decide trust, authorization, or expiry
19//! - Policy engine (separate layer) handles authorization
20//!
21//! This separation ensures backends remain testable, replaceable, and
22//! free from policy coupling.
23//!
24//! # Overwrite Semantics
25//!
26//! Different data types have different overwrite semantics:
27//!
28//! | Data Type | Semantic | Behavior |
29//! |-----------|----------|----------|
30//! | KEL Events | **Append-only** | Once written, immutable. `append_event()` refuses overwrites. |
31//! | Device Attestations | **Latest-view** | `store_attestation()` overwrites existing. History in `attestation_history/`. |
32//! | Org Member Attestations | **Latest-view** | `store_org_member()` overwrites existing for same member DID. |
33//!
34//! The "latest-view" pattern means the current file represents only the latest state.
35//! Historical data is preserved separately for audit purposes.
36
37use std::ops::ControlFlow;
38use std::sync::Arc;
39
40use auths_core::storage::keychain::IdentityDID;
41use auths_verifier::core::{Attestation, ResourceId};
42use auths_verifier::types::CanonicalDid;
43use thiserror::Error;
44
45use crate::keri::event::Event;
46use crate::keri::state::KeyState;
47use crate::keri::{Prefix, Said};
48
49use super::org_member::{MemberFilter, MemberStatus, MemberView, OrgMemberEntry};
50use super::schemas::{RegistryMetadata, TipInfo};
51
52/// An operation to be committed atomically.
53///
54/// Collected into an [`AtomicWriteBatch`] and committed via
55/// [`RegistryBackend::commit_batch`]. Git backends write all operations
56/// in a single commit; non-Git backends fall back to sequential writes.
57#[derive(Debug, Clone)]
58pub enum AtomicWriteOp {
59 /// Store a device attestation (latest-view overwrite).
60 StoreAttestation(Attestation),
61 /// Store an org member attestation (latest-view overwrite).
62 StoreOrgMember { org: String, member: Attestation },
63 /// Append a KEL event with optional CESR signature attachment.
64 AppendEvent {
65 prefix: Prefix,
66 event: Event,
67 attachment: Vec<u8>,
68 },
69 /// Append a TEL (credential-status) event under an issuer's registry.
70 ///
71 /// Staged alongside the issuer's anchoring `ixn` (an [`AtomicWriteOp::AppendEvent`])
72 /// so the TEL event and its KEL anchor land in one commit. See the
73 /// `RegistryBackend` TEL doc-block for the atomicity justification.
74 AppendTelEvent {
75 /// Issuing AID controlling the registry.
76 issuer: Prefix,
77 /// Registry SAID (`vcp.d`).
78 registry_said: Said,
79 /// Credential SAID (`iss.i`/`rev.i`; equals the registry SAID for a `vcp`).
80 credential_said: Said,
81 /// TEL event sequence number (`0` for `vcp`/`iss`, `1` for `rev`).
82 sn: u128,
83 /// The TEL event's canonical insertion-order JSON bytes.
84 event_bytes: Vec<u8>,
85 },
86 /// Store an ACDC credential blob under an issuer's namespace.
87 ///
88 /// Staged alongside the `iss` TEL event and the issuer's anchoring `ixn` so the
89 /// blob, the TEL event, and the KEL anchor land in one commit.
90 StoreCredential {
91 /// Issuing AID.
92 issuer: Prefix,
93 /// Credential SAID (`acdc.d`).
94 credential_said: Said,
95 /// The ACDC credential's canonical insertion-order JSON bytes.
96 credential_bytes: Vec<u8>,
97 },
98}
99
100/// Batch of write operations to be committed atomically.
101///
102/// Build one via [`AtomicWriteBatch::new`], stage operations, then pass
103/// to [`RegistryBackend::commit_batch`].
104#[derive(Debug, Clone, Default)]
105pub struct AtomicWriteBatch {
106 ops: Vec<AtomicWriteOp>,
107}
108
109impl AtomicWriteBatch {
110 pub fn new() -> Self {
111 Self::default()
112 }
113
114 pub fn stage_attestation(&mut self, att: Attestation) -> &mut Self {
115 self.ops.push(AtomicWriteOp::StoreAttestation(att));
116 self
117 }
118
119 pub fn stage_org_member(&mut self, org: impl Into<String>, member: Attestation) -> &mut Self {
120 self.ops.push(AtomicWriteOp::StoreOrgMember {
121 org: org.into(),
122 member,
123 });
124 self
125 }
126
127 pub fn stage_event(&mut self, prefix: Prefix, event: Event, attachment: Vec<u8>) -> &mut Self {
128 self.ops.push(AtomicWriteOp::AppendEvent {
129 prefix,
130 event,
131 attachment,
132 });
133 self
134 }
135
136 /// Stage a TEL event (`vcp`/`iss`/`rev`) under an issuer's registry.
137 pub fn stage_tel_event(
138 &mut self,
139 issuer: Prefix,
140 registry_said: Said,
141 credential_said: Said,
142 sn: u128,
143 event_bytes: Vec<u8>,
144 ) -> &mut Self {
145 self.ops.push(AtomicWriteOp::AppendTelEvent {
146 issuer,
147 registry_said,
148 credential_said,
149 sn,
150 event_bytes,
151 });
152 self
153 }
154
155 /// Stage an ACDC credential blob under an issuer's namespace.
156 pub fn stage_credential(
157 &mut self,
158 issuer: Prefix,
159 credential_said: Said,
160 credential_bytes: Vec<u8>,
161 ) -> &mut Self {
162 self.ops.push(AtomicWriteOp::StoreCredential {
163 issuer,
164 credential_said,
165 credential_bytes,
166 });
167 self
168 }
169
170 pub fn ops(&self) -> &[AtomicWriteOp] {
171 &self.ops
172 }
173
174 pub fn is_empty(&self) -> bool {
175 self.ops.is_empty()
176 }
177}
178
179/// Specific reasons a tenant ID is rejected.
180///
181/// `PathTraversal` is intentionally absent: the strict allowlist
182/// (`[a-z0-9_-]`) makes traversal components unexpressible.
183/// If the allowed charset expands (e.g. to include `.`), reintroduce
184/// an explicit traversal check before adding it to the allowlist.
185#[derive(Debug, Error, PartialEq)]
186#[non_exhaustive]
187pub enum TenantIdError {
188 /// ID is empty or exceeds 64 characters.
189 #[error("must be 1–64 characters (got {0})")]
190 InvalidLength(usize),
191
192 /// ID contains a character outside `[a-z0-9_-]`.
193 /// Carries the first offending character for precise error messages.
194 #[error("contains disallowed character {0:?} (only [a-z0-9_-] allowed)")]
195 InvalidCharacter(char),
196
197 /// ID matches a reserved route segment or system name.
198 #[error("'{0}' is reserved")]
199 Reserved(String),
200}
201
202impl auths_core::error::AuthsErrorInfo for TenantIdError {
203 fn error_code(&self) -> &'static str {
204 match self {
205 Self::InvalidLength(_) => "AUTHS-E4851",
206 Self::InvalidCharacter(_) => "AUTHS-E4852",
207 Self::Reserved(_) => "AUTHS-E4853",
208 }
209 }
210
211 fn suggestion(&self) -> Option<&'static str> {
212 match self {
213 Self::InvalidLength(_) => Some("Tenant ID must be between 1 and 64 characters"),
214 Self::InvalidCharacter(_) => {
215 Some("Only lowercase letters, digits, hyphens, and underscores are allowed")
216 }
217 Self::Reserved(_) => Some("Choose a different tenant ID; this name is reserved"),
218 }
219 }
220}
221
222/// A tenant identifier that has been normalized (lowercased) and validated.
223///
224/// Construct via [`ValidatedTenantId::new`] which enforces:
225/// - Length 1–64 characters
226/// - Only `[a-z0-9_-]` characters (input is lowercased before checking)
227/// - Not a reserved name (`admin`, `health`, `metrics`)
228///
229/// Passing this type through boundaries guarantees the ID is safe for
230/// filesystem paths without further checking.
231///
232/// Usage:
233/// ```ignore
234/// let tid = ValidatedTenantId::new("Acme")?;
235/// assert_eq!(tid.as_str(), "acme");
236/// ```
237#[derive(Debug, Clone, PartialEq, Eq, Hash)]
238pub struct ValidatedTenantId(String);
239
240/// Reserved names that collide with current or planned API route segments.
241const RESERVED_TENANT_IDS: &[&str] = &["admin", "health", "metrics"];
242
243impl ValidatedTenantId {
244 /// Normalize and validate a raw tenant identifier.
245 ///
246 /// Input is lowercased, then checked against the validation rules.
247 pub fn new(raw: impl Into<String>) -> Result<Self, RegistryError> {
248 let normalized = raw.into().to_lowercase();
249 validate_tenant_id_inner(&normalized)?;
250 Ok(Self(normalized))
251 }
252
253 /// Returns the canonical (lowercase) tenant ID string.
254 pub fn as_str(&self) -> &str {
255 &self.0
256 }
257}
258
259impl std::fmt::Display for ValidatedTenantId {
260 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
261 f.write_str(&self.0)
262 }
263}
264
265impl AsRef<str> for ValidatedTenantId {
266 fn as_ref(&self) -> &str {
267 &self.0
268 }
269}
270
271impl From<ValidatedTenantId> for String {
272 fn from(tid: ValidatedTenantId) -> Self {
273 tid.0
274 }
275}
276
277fn validate_tenant_id_inner(tenant_id: &str) -> Result<(), RegistryError> {
278 let len = tenant_id.len();
279 if len == 0 || len > 64 {
280 return Err(RegistryError::InvalidTenantId {
281 tenant_id: tenant_id.into(),
282 kind: TenantIdError::InvalidLength(len),
283 });
284 }
285 if let Some(bad_char) = tenant_id
286 .chars()
287 .find(|c| !matches!(c, 'a'..='z' | '0'..='9' | '-' | '_'))
288 {
289 return Err(RegistryError::InvalidTenantId {
290 tenant_id: tenant_id.into(),
291 kind: TenantIdError::InvalidCharacter(bad_char),
292 });
293 }
294 if RESERVED_TENANT_IDS.contains(&tenant_id) {
295 return Err(RegistryError::InvalidTenantId {
296 tenant_id: tenant_id.into(),
297 kind: TenantIdError::Reserved(tenant_id.into()),
298 });
299 }
300 Ok(())
301}
302
303/// Errors that can occur during registry operations.
304#[derive(Debug, Error)]
305#[non_exhaustive]
306pub enum RegistryError {
307 /// Storage backend operation failed
308 #[error("Storage error: {0}")]
309 Storage(#[source] Box<dyn std::error::Error + Send + Sync>),
310
311 /// Invalid KERI prefix format
312 #[error("Invalid prefix '{prefix}': {reason}")]
313 InvalidPrefix { prefix: String, reason: String },
314
315 /// Invalid device DID format
316 #[error("Invalid device DID '{did}': {reason}")]
317 InvalidDeviceDid { did: String, reason: String },
318
319 /// Event already exists at this sequence number
320 #[error("Event already exists: {prefix} seq {seq}")]
321 EventExists { prefix: String, seq: u128 },
322
323 /// Sequence number gap detected
324 #[error("Sequence gap for {prefix}: expected {expected}, got {got}")]
325 SequenceGap {
326 prefix: String,
327 expected: u128,
328 got: u128,
329 },
330
331 /// Entity not found
332 #[error("Not found: {entity_type} '{id}'")]
333 NotFound { entity_type: String, id: String },
334
335 /// JSON serialization/deserialization error
336 #[error("Serialization error: {0}")]
337 Serialization(#[from] serde_json::Error),
338
339 /// Concurrent modification detected (CAS failure)
340 #[error("Concurrent modification: {0}")]
341 ConcurrentModification(String),
342
343 /// SAID (Self-Addressing Identifier) mismatch (computed != stored)
344 #[error("SAID mismatch: expected {expected}, got {actual}")]
345 SaidMismatch { expected: String, actual: String },
346
347 /// Invalid event structure (cannot compute SAID)
348 #[error("Invalid event: {reason}")]
349 InvalidEvent { reason: String },
350
351 /// I/O error
352 #[error("I/O error: {0}")]
353 Io(#[from] std::io::Error),
354
355 /// Internal error
356 #[error("Internal error: {0}")]
357 Internal(String),
358
359 /// Invalid tenant ID
360 #[error("invalid tenant ID '{tenant_id}': {kind}")]
361 InvalidTenantId {
362 tenant_id: String,
363 kind: TenantIdError,
364 },
365
366 /// Attestation validation error
367 #[error("Attestation error: {0}")]
368 Attestation(String),
369
370 /// Stale or replayed attestation rejected
371 #[error("Stale attestation: {0}")]
372 StaleAttestation(String),
373
374 /// Method is not yet implemented by this backend.
375 #[error("Not implemented: {method}")]
376 NotImplemented {
377 /// Name of the unimplemented method.
378 method: &'static str,
379 },
380
381 /// A batch operation failed validation at a specific event index.
382 #[error("Batch validation failed at index {index}: {source}")]
383 BatchValidationFailed {
384 /// Zero-based index of the failing event in the batch.
385 index: usize,
386 /// The underlying validation error.
387 source: Box<RegistryError>,
388 },
389}
390
391impl auths_core::error::AuthsErrorInfo for RegistryError {
392 fn error_code(&self) -> &'static str {
393 match self {
394 Self::Storage(_) => "AUTHS-E4861",
395 Self::InvalidPrefix { .. } => "AUTHS-E4862",
396 Self::InvalidDeviceDid { .. } => "AUTHS-E4863",
397 Self::EventExists { .. } => "AUTHS-E4864",
398 Self::SequenceGap { .. } => "AUTHS-E4865",
399 Self::NotFound { .. } => "AUTHS-E4866",
400 Self::Serialization(_) => "AUTHS-E4867",
401 Self::ConcurrentModification(_) => "AUTHS-E4868",
402 Self::SaidMismatch { .. } => "AUTHS-E4869",
403 Self::InvalidEvent { .. } => "AUTHS-E4870",
404 Self::Io(_) => "AUTHS-E4871",
405 Self::Internal(_) => "AUTHS-E4872",
406 Self::InvalidTenantId { .. } => "AUTHS-E4873",
407 Self::Attestation(_) => "AUTHS-E4874",
408 Self::StaleAttestation(_) => "AUTHS-E4875",
409 Self::NotImplemented { .. } => "AUTHS-E4876",
410 Self::BatchValidationFailed { .. } => "AUTHS-E4877",
411 }
412 }
413
414 fn suggestion(&self) -> Option<&'static str> {
415 match self {
416 Self::Storage(_) => Some("Check storage backend connectivity"),
417 Self::InvalidPrefix { .. } => Some("KERI prefixes must start with 'E' (Blake3 SAID)"),
418 Self::InvalidDeviceDid { .. } => Some("Device DIDs must be in 'did:key:z...' format"),
419 Self::EventExists { .. } => Some("This event has already been appended to the KEL"),
420 Self::SequenceGap { .. } => Some("Events must be appended in strict sequence order"),
421 Self::NotFound { .. } => None,
422 Self::Serialization(_) => None,
423 Self::ConcurrentModification(_) => {
424 Some("Retry the operation; another process modified the registry")
425 }
426 Self::SaidMismatch { .. } => Some("The event content does not match its declared SAID"),
427 Self::InvalidEvent { .. } => None,
428 Self::Io(_) => Some("Check file permissions and disk space"),
429 Self::Internal(_) => None,
430 Self::InvalidTenantId { .. } => None,
431 Self::Attestation(_) => None,
432 Self::StaleAttestation(_) => {
433 Some("The attestation has been superseded by a newer version")
434 }
435 Self::NotImplemented { .. } => {
436 Some("This operation is not supported by the current backend")
437 }
438 Self::BatchValidationFailed { .. } => None,
439 }
440 }
441}
442
443impl RegistryError {
444 /// Create a storage error from any error type.
445 ///
446 /// Use this to wrap backend-specific errors (e.g., git2::Error) in the
447 /// generic Storage variant.
448 pub fn storage<E: std::error::Error + Send + Sync + 'static>(err: E) -> Self {
449 Self::Storage(Box::new(err))
450 }
451
452 /// Create a not-found error for an identity.
453 pub fn identity_not_found(prefix: &Prefix) -> Self {
454 Self::NotFound {
455 entity_type: "identity".into(),
456 id: prefix.as_str().into(),
457 }
458 }
459
460 /// Create a not-found error for an event.
461 pub fn event_not_found(prefix: &Prefix, seq: u128) -> Self {
462 Self::NotFound {
463 entity_type: "event".into(),
464 id: format!("{} seq {}", prefix.as_str(), seq),
465 }
466 }
467
468 /// Create a not-found error for a device.
469 pub fn device_not_found(did: &str) -> Self {
470 Self::NotFound {
471 entity_type: "device".into(),
472 id: did.into(),
473 }
474 }
475
476 /// Create a not-found error for an org.
477 pub fn org_not_found(org: &str) -> Self {
478 Self::NotFound {
479 entity_type: "org".into(),
480 id: org.into(),
481 }
482 }
483}
484
485/// Visitor over stored ACDC credential blobs: `(issuer, credential_said, bytes)`.
486///
487/// Aliased to keep the [`RegistryBackend::visit_credentials`] signature readable.
488pub type CredentialVisitor<'a> = dyn FnMut(&Prefix, &Said, &[u8]) -> ControlFlow<()> + 'a;
489
490/// Visitor over persisted TEL coordinates: `(issuer, registry_said, credential_said)`.
491///
492/// Aliased to keep the [`RegistryBackend::visit_tel_registries`] signature readable.
493pub type TelRegistryVisitor<'a> = dyn FnMut(&Prefix, &Said, &Said) -> ControlFlow<()> + 'a;
494
495/// Trait for registry storage backends.
496///
497/// This trait defines operations for storing and retrieving:
498/// - KERI events and key states
499/// - Device attestations
500/// - Organization memberships
501///
502/// Implementations must be thread-safe (`Send + Sync`).
503///
504/// # FROZEN SURFACE
505///
506/// This trait's method set is frozen. Do not add new methods without:
507/// 1. Documented justification in a doc comment
508/// 2. Review confirming the method is required for registry operations
509///
510/// The registry is a cache/index, not a source of truth. New methods should
511/// only be added if they are essential for indexing or retrieval operations.
512pub trait RegistryBackend: Send + Sync {
513 // (visitor closure aliases for the credential/TEL enumeration surface live
514 // at module scope below — see `CredentialVisitor` / `TelRegistryVisitor`.)
515 // =========================================================================
516 // KEL Operations
517 // =========================================================================
518
519 /// Append an event to an identity's KEL.
520 ///
521 /// # Semantics: Append-Only
522 ///
523 /// Events are **append-only** - once written, they are immutable.
524 /// This function **refuses to overwrite** existing events.
525 ///
526 /// # Constraints (enforced by implementation)
527 ///
528 /// - Refuses if event file already exists (append-only, no rewrites)
529 /// - Refuses if seq != tip.sequence + 1 (except seq 0 for inception)
530 /// - Validates event.said() matches content hash expectations
531 ///
532 /// Also writes state.json with updated KeyState after each append.
533 ///
534 /// # Arguments
535 ///
536 /// * `prefix` - The KERI identifier prefix
537 /// * `event` - The event to append
538 fn append_event(&self, prefix: &Prefix, event: &Event) -> Result<(), RegistryError>;
539
540 /// Append an event together with its CESR-attachment bytes (externalized
541 /// signatures and other CESR groups).
542 ///
543 /// REQUIRED — no default (RT-002). A default that delegated to `append_event`
544 /// silently DROPPED the attachment, so backends lost event signatures and the
545 /// verify path could only replay KELs structurally — the exact gap RT-002
546 /// exploits. Every backend MUST persist the attachment so a KEL can be
547 /// AUTHENTICATED, not merely structurally replayed. (This tightens two
548 /// existing methods; it does not expand the frozen surface.)
549 fn append_signed_event(
550 &self,
551 prefix: &Prefix,
552 event: &Event,
553 attachment: &[u8],
554 ) -> Result<(), RegistryError>;
555
556 /// Read the CESR-attachment bytes for an event at the given sequence, or
557 /// `None` if the event genuinely carries no attachment.
558 ///
559 /// REQUIRED — no default (RT-002). A default returning `None` made stored
560 /// signatures unreadable — the read-side half of the same gap.
561 fn get_attachment(&self, prefix: &Prefix, seq: u128) -> Result<Option<Vec<u8>>, RegistryError>;
562
563 /// Get a single event by sequence number (random access).
564 ///
565 /// # Arguments
566 ///
567 /// * `prefix` - The KERI identifier prefix
568 /// * `seq` - The sequence number of the event
569 fn get_event(&self, prefix: &Prefix, seq: u128) -> Result<Event, RegistryError>;
570
571 /// Visit events starting from a sequence (streaming via visitor pattern).
572 ///
573 /// Calls `visitor` for each event. Return `ControlFlow::Break(())` to stop early.
574 ///
575 /// # Arguments
576 ///
577 /// * `prefix` - The KERI identifier prefix
578 /// * `from_seq` - Start reading from this sequence number
579 /// * `visitor` - Callback invoked for each event
580 fn visit_events(
581 &self,
582 prefix: &Prefix,
583 from_seq: u128,
584 visitor: &mut dyn FnMut(&Event) -> ControlFlow<()>,
585 ) -> Result<(), RegistryError>;
586
587 /// Get tip info without reading events.
588 ///
589 /// # Arguments
590 ///
591 /// * `prefix` - The KERI identifier prefix
592 fn get_tip(&self, prefix: &Prefix) -> Result<TipInfo, RegistryError>;
593
594 /// Get key state.
595 ///
596 /// First tries state.json (verified against tip.said).
597 /// Falls back to full KEL replay if state.json missing or stale.
598 ///
599 /// # Arguments
600 ///
601 /// * `prefix` - The KERI identifier prefix
602 fn get_key_state(&self, prefix: &Prefix) -> Result<KeyState, RegistryError>;
603
604 /// Write a pre-computed `KeyState` directly into the registry tree.
605 ///
606 /// # When to Use
607 ///
608 /// This is for the archival worker's write-through path: the worker
609 /// receives a `KeyState` from the mpsc channel (which was already
610 /// validated and written to Redis Tier 0) and needs to persist it
611 /// to Git Tier 1 without re-running KEL validation.
612 ///
613 /// This is **not** the append path for new KERI events. Use `append_event`
614 /// for that. This method only updates `state.json` — it does not append
615 /// any event blob or update `tip.json`. This is valid because the caller
616 /// received the `KeyState` from a successful `append_event` call.
617 ///
618 /// # Arguments
619 ///
620 /// * `prefix` - The KERI identifier prefix
621 /// * `state` - The pre-validated `KeyState` to persist
622 fn write_key_state(&self, prefix: &Prefix, state: &KeyState) -> Result<(), RegistryError>;
623
624 /// Visit all identity prefixes (streaming for tooling/export).
625 ///
626 /// Calls `visitor` for each identity prefix. Return `ControlFlow::Break(())` to stop early.
627 fn visit_identities(
628 &self,
629 visitor: &mut dyn FnMut(&str) -> ControlFlow<()>,
630 ) -> Result<(), RegistryError>;
631
632 // =========================================================================
633 // Attestation Operations
634 // =========================================================================
635
636 /// Store a device attestation.
637 ///
638 /// # Semantics: Latest-View (overwrites)
639 ///
640 /// Attestations use **latest-view** semantics - the current file represents
641 /// the latest state only. This function **overwrites any existing attestation**
642 /// for the same device DID.
643 ///
644 /// Historical attestations are preserved in `attestation_history/` via
645 /// `visit_attestation_history`.
646 ///
647 /// # Arguments
648 ///
649 /// * `attestation` - The attestation to store
650 fn store_attestation(&self, attestation: &Attestation) -> Result<(), RegistryError>;
651
652 /// Load an attestation for a device.
653 ///
654 /// Returns `None` if no attestation exists for the device.
655 ///
656 /// # Arguments
657 ///
658 /// * `did` - The device DID
659 fn load_attestation(&self, did: &CanonicalDid) -> Result<Option<Attestation>, RegistryError>;
660
661 /// Visit attestation history for a device (append-only audit trail).
662 ///
663 /// Iterates historical attestations in chronological order (oldest first).
664 /// Each entry represents a point-in-time snapshot of the device's attestation.
665 ///
666 /// # Arguments
667 ///
668 /// * `did` - The device DID
669 /// * `visitor` - Callback invoked for each historical attestation
670 fn visit_attestation_history(
671 &self,
672 did: &CanonicalDid,
673 visitor: &mut dyn FnMut(&Attestation) -> ControlFlow<()>,
674 ) -> Result<(), RegistryError>;
675
676 /// Visit all device DIDs in the registry.
677 ///
678 /// Calls `visitor` for each device DID. Return `ControlFlow::Break(())` to stop early.
679 fn visit_devices(
680 &self,
681 visitor: &mut dyn FnMut(&CanonicalDid) -> ControlFlow<()>,
682 ) -> Result<(), RegistryError>;
683
684 // =========================================================================
685 // Org Operations
686 // =========================================================================
687
688 /// Store an org member attestation.
689 ///
690 /// # Semantics: Latest-View (overwrites)
691 ///
692 /// Member attestations use **latest-view** semantics - the current file represents
693 /// the latest state only. This function **overwrites any existing attestation**
694 /// for the same member DID within the org.
695 ///
696 /// # Arguments
697 ///
698 /// * `org` - The org DID prefix
699 /// * `member` - The member's attestation
700 fn store_org_member(&self, org: &str, member: &Attestation) -> Result<(), RegistryError>;
701
702 /// Visit all members of an org (low-level, validation-aware).
703 ///
704 /// Iterates all member entries with their validation status.
705 /// Even invalid entries call visitor (for audit/tooling).
706 ///
707 /// # Arguments
708 ///
709 /// * `org` - The org DID prefix
710 /// * `visitor` - Callback invoked for each member entry
711 fn visit_org_member_attestations(
712 &self,
713 org: &str,
714 visitor: &mut dyn FnMut(&OrgMemberEntry) -> ControlFlow<()>,
715 ) -> Result<(), RegistryError>;
716
717 /// List org members with filtering (high-level API).
718 ///
719 /// Returns validated, filtered member views sorted deterministically by DID.
720 ///
721 /// # No Policy Decisions
722 ///
723 /// This method does NOT compute revoked/expired status. All valid attestations
724 /// are returned with `status: Active`. Use the `revoked_at` and `expires_at` fields
725 /// to compute actual status in your policy layer:
726 ///
727 /// ```ignore
728 /// let actual_status = compute_status_from_view(&view, now);
729 /// ```
730 ///
731 /// The `include_statuses` filter is ignored (status filtering is policy). The
732 /// `roles_any`/`capabilities_*` filters are also inert: role/caps authority is
733 /// KEL-native (delegator-anchored scope seal), not the attestation, so this method
734 /// no longer reads attestation role/caps to filter or populate views.
735 ///
736 /// # Arguments
737 ///
738 /// * `org` - The org DID prefix
739 /// * `_filter` - Filter criteria (retained for API stability; role/caps filters are inert)
740 fn list_org_members(
741 &self,
742 org: &str,
743 _filter: &MemberFilter,
744 ) -> Result<Vec<MemberView>, RegistryError> {
745 let mut members = Vec::new();
746
747 self.visit_org_member_attestations(org, &mut |entry| {
748 let (status, att_opt, revoked_at) = match &entry.attestation {
749 Ok(att) => (MemberStatus::Active, Some(att), att.revoked_at),
750 Err(reason) => (
751 MemberStatus::Invalid {
752 reason: reason.clone(),
753 },
754 None,
755 None,
756 ),
757 };
758
759 // For valid attestations, surface identity facts only.
760 //
761 // Role/capabilities authority is KEL-native (the delegator-anchored scope
762 // seal — see `crate::storage::registry::org_member` and the SDK org
763 // delegation path), NOT the attestation. The scope-seal-derived values are
764 // not available at this storage seam, so `MemberView.role`/`.capabilities`
765 // are left empty here (display-only, fail-closed): a consumer that needs
766 // authority must resolve it from the KEL, never from these fields.
767 // The `roles_any`/`capabilities_*` filters are no longer applied for the
768 // same reason — filtering on attestation-borne role/caps would re-introduce
769 // the retired authority reader.
770 if let Some(att) = att_opt {
771 // Fail closed: an attestation whose issuer is not a `did:keri:`
772 // identity is dropped from the view rather than surfaced with an
773 // unvalidated issuer.
774 let Ok(issuer) = IdentityDID::parse(att.issuer.as_str()) else {
775 return ControlFlow::Continue(());
776 };
777 members.push(MemberView {
778 did: entry.did.clone(),
779 status,
780 role: None,
781 capabilities: vec![],
782 issuer,
783 rid: att.rid.clone(),
784 revoked_at,
785 expires_at: att.expires_at,
786 timestamp: att.timestamp,
787 source_filename: entry.filename.clone(),
788 });
789 } else {
790 members.push(MemberView {
791 did: entry.did.clone(),
792 status,
793 role: None,
794 capabilities: vec![],
795 issuer: entry.org.clone(),
796 rid: ResourceId::new(""),
797 revoked_at: None,
798 expires_at: None,
799 timestamp: None,
800 source_filename: entry.filename.clone(),
801 });
802 }
803
804 ControlFlow::Continue(())
805 })?;
806
807 // Sort deterministically by DID only (status not computed here)
808 members.sort_by(|a, b| a.did.to_string().cmp(&b.did.to_string()));
809
810 Ok(members)
811 }
812
813 // =========================================================================
814 // Lifecycle
815 // =========================================================================
816
817 /// Initialize the registry if it has not been set up yet.
818 ///
819 /// Creates the initial registry structure (e.g., first Git commit, database
820 /// schema). Returns `true` if initialization was performed, `false` if the
821 /// registry was already initialized.
822 ///
823 /// Usage:
824 /// ```ignore
825 /// let was_initialized = backend.init_if_needed()?;
826 /// ```
827 fn init_if_needed(&self) -> Result<bool, RegistryError>;
828
829 // =========================================================================
830 // Metadata
831 // =========================================================================
832
833 /// Get registry metadata.
834 fn metadata(&self) -> Result<RegistryMetadata, RegistryError>;
835
836 // =========================================================================
837 // Fast-path operations (index-accelerated)
838 // =========================================================================
839
840 /// List org members using the SQLite index when available.
841 ///
842 /// # Justification for addition to frozen surface
843 ///
844 /// This is a performance optimization of the existing `list_org_members` method,
845 /// not a new capability. The default implementation delegates to `list_org_members`
846 /// (O(n) Git scan). Backends with an index override this for O(1) SQLite lookup.
847 ///
848 /// # Filter limitations
849 ///
850 /// The index stores only DID, rid, issuer, revoked_at, and expires_at.
851 /// If the filter includes `roles_any`, `capabilities_any`, or `capabilities_all`,
852 /// the implementation falls back to the Git scan (those fields require the full
853 /// attestation blob). Status filtering is always deferred to the policy layer.
854 ///
855 /// # Arguments
856 ///
857 /// * `org` - The org DID prefix
858 /// * `filter` - Filter criteria for members
859 fn list_org_members_fast(
860 &self,
861 org: &str,
862 filter: &MemberFilter,
863 ) -> Result<Vec<MemberView>, RegistryError> {
864 self.list_org_members(org, filter)
865 }
866
867 // =========================================================================
868 // TEL (Transaction Event Log) Operations (FROZEN-TRAIT EXCEPTION)
869 //
870 // Justification (atomicity — same exception that justified the write-batch
871 // surface above): a credential issuance writes three artifacts that MUST be
872 // consistent — the ACDC blob, the `iss` TEL event, and the issuer KEL `ixn`
873 // carrying the TEL anchor seal. A crash between any two leaves a dangling
874 // state: an anchored-but-absent TEL event, or a credential blob with no
875 // status log, or a TEL event the KEL never anchored (forgeable). These three
876 // writes therefore commit together via `commit_batch`, exactly as the
877 // attestation-blob + KEL-ixn pair does. The TEL is a derived, KEL-anchored
878 // index (the registry remains a cache, not a source of truth); these methods
879 // add only persistence + retrieval of that index, never trust decisions.
880 //
881 // Reviewed and approved as a frozen-surface extension for Epic F (credential
882 // registry). Backends that do not persist a TEL may leave the default
883 // `NotImplemented` stubs.
884 // =========================================================================
885
886 /// Append a TEL event (`vcp`/`iss`/`rev`) under an issuer's registry.
887 ///
888 /// # Semantics: Append-Only
889 ///
890 /// TEL events are immutable once written. Implementations refuse to overwrite
891 /// an existing event at the same `(registry, credential, sn)` coordinate.
892 ///
893 /// Prefer staging this in an [`AtomicWriteBatch`] alongside the anchoring `ixn`
894 /// (and, for an `iss`, the ACDC blob) so all three land in one commit.
895 ///
896 /// # Arguments
897 ///
898 /// * `issuer` - Issuing AID controlling the registry.
899 /// * `registry_said` - Registry SAID (`vcp.d`).
900 /// * `credential_said` - Credential SAID (equals the registry SAID for a `vcp`).
901 /// * `sn` - TEL event sequence number.
902 /// * `event_bytes` - The TEL event's canonical insertion-order JSON bytes.
903 fn append_tel_event(
904 &self,
905 _issuer: &Prefix,
906 _registry_said: &Said,
907 _credential_said: &Said,
908 _sn: u128,
909 _event_bytes: &[u8],
910 ) -> Result<(), RegistryError> {
911 Err(RegistryError::NotImplemented {
912 method: "append_tel_event",
913 })
914 }
915
916 /// Visit the persisted TEL events for one credential, oldest first.
917 ///
918 /// Calls `visitor` with each event's raw JSON bytes in ascending `sn` order.
919 /// Return `ControlFlow::Break(())` to stop early. A credential with no TEL
920 /// events visits nothing and returns `Ok(())`.
921 ///
922 /// # Arguments
923 ///
924 /// * `issuer` - Issuing AID.
925 /// * `registry_said` - Registry SAID.
926 /// * `credential_said` - Credential SAID.
927 /// * `visitor` - Callback invoked for each TEL event's JSON bytes.
928 fn visit_tel_events(
929 &self,
930 _issuer: &Prefix,
931 _registry_said: &Said,
932 _credential_said: &Said,
933 _visitor: &mut dyn FnMut(&[u8]) -> ControlFlow<()>,
934 ) -> Result<(), RegistryError> {
935 Err(RegistryError::NotImplemented {
936 method: "visit_tel_events",
937 })
938 }
939
940 /// Store an ACDC credential blob under an issuer's namespace.
941 ///
942 /// Latest-view by credential SAID; since a credential SAID is content-addressed,
943 /// re-storing the same SAID is idempotent.
944 ///
945 /// # Arguments
946 ///
947 /// * `issuer` - Issuing AID.
948 /// * `credential_said` - Credential SAID (`acdc.d`).
949 /// * `credential_bytes` - The ACDC's canonical insertion-order JSON bytes.
950 fn store_credential(
951 &self,
952 _issuer: &Prefix,
953 _credential_said: &Said,
954 _credential_bytes: &[u8],
955 ) -> Result<(), RegistryError> {
956 Err(RegistryError::NotImplemented {
957 method: "store_credential",
958 })
959 }
960
961 /// Load an ACDC credential blob, or `None` if absent.
962 ///
963 /// # Arguments
964 ///
965 /// * `issuer` - Issuing AID.
966 /// * `credential_said` - Credential SAID.
967 fn load_credential(
968 &self,
969 _issuer: &Prefix,
970 _credential_said: &Said,
971 ) -> Result<Option<Vec<u8>>, RegistryError> {
972 Err(RegistryError::NotImplemented {
973 method: "load_credential",
974 })
975 }
976
977 /// Visit every stored ACDC credential blob, in `(issuer, credential)` order.
978 ///
979 /// Calls `visitor` with each credential's `(issuer, credential_said,
980 /// credential_bytes)`. Return `ControlFlow::Break(())` to stop early. A
981 /// registry holding no credentials visits nothing and returns `Ok(())`.
982 ///
983 /// This is the enumeration half of the credential surface (the keyed reader
984 /// is [`load_credential`]): tooling that must reconstruct a whole fleet on a
985 /// cold machine (`registry pull`) has no way to know the credential SAIDs in
986 /// advance, so it walks them here.
987 ///
988 /// # Arguments
989 ///
990 /// * `visitor` - Callback invoked for each credential blob.
991 fn visit_credentials(&self, _visitor: &mut CredentialVisitor<'_>) -> Result<(), RegistryError> {
992 Err(RegistryError::NotImplemented {
993 method: "visit_credentials",
994 })
995 }
996
997 /// Visit every persisted TEL registry coordinate, in tree order.
998 ///
999 /// Calls `visitor` with each `(issuer, registry_said, credential_said)`
1000 /// directory that holds TEL events — including the registry's own `vcp`
1001 /// slot, where `credential_said == registry_said`. Return
1002 /// `ControlFlow::Break(())` to stop early. A registry holding no TEL visits
1003 /// nothing and returns `Ok(())`.
1004 ///
1005 /// Pairs with [`visit_tel_events`] (which streams one coordinate's events):
1006 /// enumeration tooling (`registry pull`) discovers the coordinates here, then
1007 /// reads each one's events to copy them onto the cold machine.
1008 ///
1009 /// # Arguments
1010 ///
1011 /// * `visitor` - Callback invoked for each TEL coordinate.
1012 fn visit_tel_registries(
1013 &self,
1014 _visitor: &mut TelRegistryVisitor<'_>,
1015 ) -> Result<(), RegistryError> {
1016 Err(RegistryError::NotImplemented {
1017 method: "visit_tel_registries",
1018 })
1019 }
1020
1021 // =========================================================================
1022 // Atomic Batch Writes (FROZEN-TRAIT EXCEPTION)
1023 //
1024 // Justification: Attestation blob + KEL ixn event must land in a single
1025 // Git commit. Without this, a crash between two writes leaves orphaned
1026 // blobs or dangling seals — a security gap where forged attestations
1027 // could exist without KEL evidence. See verification_bindings.md Gap 1.
1028 // =========================================================================
1029
1030 /// Commit a batch of write operations atomically.
1031 ///
1032 /// Git backends override this to stage all operations in a single
1033 /// `TreeMutator` and produce one commit. The default implementation
1034 /// falls back to sequential individual writes (non-atomic, acceptable
1035 /// for in-memory test backends).
1036 fn commit_batch(&self, batch: &AtomicWriteBatch) -> Result<(), RegistryError> {
1037 for op in batch.ops() {
1038 match op {
1039 AtomicWriteOp::StoreAttestation(att) => self.store_attestation(att)?,
1040 AtomicWriteOp::StoreOrgMember { org, member } => {
1041 self.store_org_member(org, member)?
1042 }
1043 AtomicWriteOp::AppendEvent {
1044 prefix,
1045 event,
1046 attachment,
1047 } => {
1048 if attachment.is_empty() {
1049 self.append_event(prefix, event)?;
1050 } else {
1051 self.append_signed_event(prefix, event, attachment)?;
1052 }
1053 }
1054 AtomicWriteOp::AppendTelEvent {
1055 issuer,
1056 registry_said,
1057 credential_said,
1058 sn,
1059 event_bytes,
1060 } => {
1061 self.append_tel_event(issuer, registry_said, credential_said, *sn, event_bytes)?
1062 }
1063 AtomicWriteOp::StoreCredential {
1064 issuer,
1065 credential_said,
1066 credential_bytes,
1067 } => self.store_credential(issuer, credential_said, credential_bytes)?,
1068 }
1069 }
1070 Ok(())
1071 }
1072}
1073
1074/// Blanket impl so `Arc<dyn RegistryBackend + Send + Sync>` can be used directly
1075/// as a `RegistryBackend` at call sites (e.g., tests, extractors).
1076impl<T: RegistryBackend + ?Sized> RegistryBackend for Arc<T> {
1077 fn append_event(&self, prefix: &Prefix, event: &Event) -> Result<(), RegistryError> {
1078 (**self).append_event(prefix, event)
1079 }
1080
1081 // RT-002 root cause: this blanket impl forwards every method EXCEPT these two,
1082 // so before the trait defaults were removed, `Arc<dyn RegistryBackend>` (how
1083 // the SDK holds `ctx.registry`) silently dropped attachments on write and
1084 // returned None on read — the inception's signature was lost, and the bundle
1085 // producer saw `get_attachment(prefix, 0) == None`. Forward both.
1086 fn append_signed_event(
1087 &self,
1088 prefix: &Prefix,
1089 event: &Event,
1090 attachment: &[u8],
1091 ) -> Result<(), RegistryError> {
1092 (**self).append_signed_event(prefix, event, attachment)
1093 }
1094
1095 fn get_attachment(&self, prefix: &Prefix, seq: u128) -> Result<Option<Vec<u8>>, RegistryError> {
1096 (**self).get_attachment(prefix, seq)
1097 }
1098
1099 fn get_event(&self, prefix: &Prefix, seq: u128) -> Result<Event, RegistryError> {
1100 (**self).get_event(prefix, seq)
1101 }
1102
1103 fn visit_events(
1104 &self,
1105 prefix: &Prefix,
1106 from_seq: u128,
1107 visitor: &mut dyn FnMut(&Event) -> ControlFlow<()>,
1108 ) -> Result<(), RegistryError> {
1109 (**self).visit_events(prefix, from_seq, visitor)
1110 }
1111
1112 fn get_tip(&self, prefix: &Prefix) -> Result<TipInfo, RegistryError> {
1113 (**self).get_tip(prefix)
1114 }
1115
1116 fn get_key_state(&self, prefix: &Prefix) -> Result<KeyState, RegistryError> {
1117 (**self).get_key_state(prefix)
1118 }
1119
1120 fn write_key_state(&self, prefix: &Prefix, state: &KeyState) -> Result<(), RegistryError> {
1121 (**self).write_key_state(prefix, state)
1122 }
1123
1124 fn visit_identities(
1125 &self,
1126 visitor: &mut dyn FnMut(&str) -> ControlFlow<()>,
1127 ) -> Result<(), RegistryError> {
1128 (**self).visit_identities(visitor)
1129 }
1130
1131 fn store_attestation(&self, attestation: &Attestation) -> Result<(), RegistryError> {
1132 (**self).store_attestation(attestation)
1133 }
1134
1135 fn load_attestation(&self, did: &CanonicalDid) -> Result<Option<Attestation>, RegistryError> {
1136 (**self).load_attestation(did)
1137 }
1138
1139 fn visit_attestation_history(
1140 &self,
1141 did: &CanonicalDid,
1142 visitor: &mut dyn FnMut(&Attestation) -> ControlFlow<()>,
1143 ) -> Result<(), RegistryError> {
1144 (**self).visit_attestation_history(did, visitor)
1145 }
1146
1147 fn visit_devices(
1148 &self,
1149 visitor: &mut dyn FnMut(&CanonicalDid) -> ControlFlow<()>,
1150 ) -> Result<(), RegistryError> {
1151 (**self).visit_devices(visitor)
1152 }
1153
1154 fn store_org_member(&self, org: &str, member: &Attestation) -> Result<(), RegistryError> {
1155 (**self).store_org_member(org, member)
1156 }
1157
1158 fn visit_org_member_attestations(
1159 &self,
1160 org: &str,
1161 visitor: &mut dyn FnMut(&OrgMemberEntry) -> ControlFlow<()>,
1162 ) -> Result<(), RegistryError> {
1163 (**self).visit_org_member_attestations(org, visitor)
1164 }
1165
1166 fn init_if_needed(&self) -> Result<bool, RegistryError> {
1167 (**self).init_if_needed()
1168 }
1169
1170 fn metadata(&self) -> Result<RegistryMetadata, RegistryError> {
1171 (**self).metadata()
1172 }
1173
1174 fn append_tel_event(
1175 &self,
1176 issuer: &Prefix,
1177 registry_said: &Said,
1178 credential_said: &Said,
1179 sn: u128,
1180 event_bytes: &[u8],
1181 ) -> Result<(), RegistryError> {
1182 (**self).append_tel_event(issuer, registry_said, credential_said, sn, event_bytes)
1183 }
1184
1185 fn visit_tel_events(
1186 &self,
1187 issuer: &Prefix,
1188 registry_said: &Said,
1189 credential_said: &Said,
1190 visitor: &mut dyn FnMut(&[u8]) -> ControlFlow<()>,
1191 ) -> Result<(), RegistryError> {
1192 (**self).visit_tel_events(issuer, registry_said, credential_said, visitor)
1193 }
1194
1195 fn store_credential(
1196 &self,
1197 issuer: &Prefix,
1198 credential_said: &Said,
1199 credential_bytes: &[u8],
1200 ) -> Result<(), RegistryError> {
1201 (**self).store_credential(issuer, credential_said, credential_bytes)
1202 }
1203
1204 fn load_credential(
1205 &self,
1206 issuer: &Prefix,
1207 credential_said: &Said,
1208 ) -> Result<Option<Vec<u8>>, RegistryError> {
1209 (**self).load_credential(issuer, credential_said)
1210 }
1211
1212 fn visit_credentials(&self, visitor: &mut CredentialVisitor<'_>) -> Result<(), RegistryError> {
1213 (**self).visit_credentials(visitor)
1214 }
1215
1216 fn visit_tel_registries(
1217 &self,
1218 visitor: &mut TelRegistryVisitor<'_>,
1219 ) -> Result<(), RegistryError> {
1220 (**self).visit_tel_registries(visitor)
1221 }
1222
1223 fn commit_batch(&self, batch: &AtomicWriteBatch) -> Result<(), RegistryError> {
1224 (**self).commit_batch(batch)
1225 }
1226}
1227
1228#[cfg(test)]
1229mod tests {
1230 use super::*;
1231
1232 #[test]
1233 fn error_identity_not_found() {
1234 let prefix = Prefix::new_unchecked("ETest123".to_string());
1235 let err = RegistryError::identity_not_found(&prefix);
1236 assert!(err.to_string().contains("identity"));
1237 assert!(err.to_string().contains("ETest123"));
1238 }
1239
1240 #[test]
1241 fn error_event_not_found() {
1242 let prefix = Prefix::new_unchecked("ETest123".to_string());
1243 let err = RegistryError::event_not_found(&prefix, 5);
1244 assert!(err.to_string().contains("event"));
1245 assert!(err.to_string().contains("seq 5"));
1246 }
1247
1248 #[test]
1249 fn error_device_not_found() {
1250 let err = RegistryError::device_not_found("did:key:test");
1251 assert!(err.to_string().contains("device"));
1252 assert!(err.to_string().contains("did:key:test"));
1253 }
1254
1255 #[test]
1256 fn error_sequence_gap() {
1257 let err = RegistryError::SequenceGap {
1258 prefix: "ETest".into(),
1259 expected: 5,
1260 got: 7,
1261 };
1262 assert!(err.to_string().contains("expected 5"));
1263 assert!(err.to_string().contains("got 7"));
1264 }
1265
1266 #[test]
1267 fn error_concurrent_modification() {
1268 let err = RegistryError::ConcurrentModification("Registry was modified".into());
1269 assert!(err.to_string().contains("Concurrent"));
1270 assert!(err.to_string().contains("modified"));
1271 }
1272
1273 #[test]
1274 fn validated_tenant_id_normalizes_to_lowercase() {
1275 let tid = ValidatedTenantId::new("ACME").unwrap();
1276 assert_eq!(tid.as_str(), "acme");
1277 }
1278
1279 #[test]
1280 fn validated_tenant_id_rejects_empty() {
1281 assert!(ValidatedTenantId::new("").is_err());
1282 }
1283
1284 #[test]
1285 fn validated_tenant_id_rejects_reserved() {
1286 assert!(ValidatedTenantId::new("admin").is_err());
1287 }
1288
1289 #[test]
1290 fn validated_tenant_id_rejects_bad_chars() {
1291 assert!(ValidatedTenantId::new("acme/sub").is_err());
1292 assert!(ValidatedTenantId::new("../escape").is_err());
1293 }
1294
1295 #[test]
1296 fn validated_tenant_id_accepts_valid() {
1297 let tid = ValidatedTenantId::new("my-tenant_123").unwrap();
1298 assert_eq!(tid.as_str(), "my-tenant_123");
1299 }
1300
1301 #[test]
1302 fn validated_tenant_id_display() {
1303 let tid = ValidatedTenantId::new("acme").unwrap();
1304 assert_eq!(format!("{}", tid), "acme");
1305 }
1306
1307 #[test]
1308 fn validated_tenant_id_into_string() {
1309 let tid = ValidatedTenantId::new("acme").unwrap();
1310 let s: String = tid.into();
1311 assert_eq!(s, "acme");
1312 }
1313}