Skip to main content

vta_keyspaces/
lib.rs

1//! Central registry of the VTA's keyspace names.
2//!
3//! Every `store.keyspace(..)` call in the VTA (`vta-service` server, offline
4//! CLIs, backup, tests) names its keyspace through a `const` here rather than a
5//! bare string literal. This is the single source of truth that killed the
6//! `"imported"` / `"imported_secrets"` test-vs-production divergence (a test
7//! opened a *different*, empty keyspace than the one production writes). The
8//! `no_bare_keyspace_literals` guard in `vta-service` keeps it that way by
9//! scanning that crate's source for bare `.keyspace("…")` literals.
10//!
11//! Keyspace *names* live here; per-keyspace *key formats* (the `key:`, `seed:`,
12//! `path_counter:` … record families inside a keyspace) are a separate concern
13//! and are not yet centralised.
14//!
15//! A near-leaf crate: it holds the shared keyspace vocabulary (the name
16//! constants) plus the [`Keyspaces`] handle bundle, so that every VTA subsystem
17//! crate can name and pass keyspaces without depending on `vta-service`. Its
18//! only dependency is `vti-common` (for `KeyspaceHandle`).
19
20use vti_common::store::KeyspaceHandle;
21
22/// Shared bundle of borrowed keyspace handles passed to operations that need
23/// several keyspaces at once.
24///
25/// The struct is a pure field bundle — the constructors that borrow it from a
26/// concrete `AppState` / `VtaState` live in `vta-service` (they know those
27/// types), so this stays free of any `vta-service` dependency.
28pub struct Keyspaces<'a> {
29    pub keys: &'a KeyspaceHandle,
30    pub acl: &'a KeyspaceHandle,
31    pub contexts: &'a KeyspaceHandle,
32    pub did_templates: &'a KeyspaceHandle,
33    pub audit: &'a KeyspaceHandle,
34    pub imported: &'a KeyspaceHandle,
35    #[cfg(feature = "webvh")]
36    pub webvh: &'a KeyspaceHandle,
37}
38
39/// Master seed + key records (`key:`, `seed:`, `path_counter:`,
40/// `active_seed_id`, `imported_kek_salt`, …) and the backup import sentinel.
41pub const KEYS: &str = "keys";
42/// Auth sessions + challenges.
43pub const SESSIONS: &str = "sessions";
44/// ACL entries + the seal record + the integrity-anchor root.
45pub const ACL: &str = "acl";
46/// Trust contexts (the BIP-32 key hierarchy roots).
47pub const CONTEXTS: &str = "contexts";
48/// Stored DID templates (global + context-scoped).
49pub const DID_TEMPLATES: &str = "did_templates";
50/// Audit log.
51pub const AUDIT: &str = "audit";
52/// The keyed-hash keys the audit log commits actor and target identifiers
53/// under, and their history.
54///
55/// Separate from [`AUDIT`] because the two have opposite lifetimes: entries
56/// are erased when their retention expires, and a key must outlive every entry
57/// that references it or those entries stop being checkable against a
58/// candidate identifier.
59pub const AUDIT_KEY: &str = "audit_key";
60/// Imported secret material (KEK-wrapped). Named `imported_secrets`, **not**
61/// `imported` — the latter was a long-standing test-only typo that operated on
62/// an empty keyspace disjoint from production. Always reference this const.
63pub const IMPORTED_SECRETS: &str = "imported_secrets";
64/// Non-extractable internal signing keys.
65///
66/// Deliberately **not** [`IMPORTED_SECRETS`]: that keyspace wraps its contents
67/// under a KEK derived from the BIP-39 master seed, so anything stored there is
68/// reconstructible by whoever holds the mnemonic. Internal keys exist precisely
69/// to have no such path — their material is generated from the system CSPRNG,
70/// never derived, and lives here instead.
71///
72/// In [`EXCLUDED_FROM_BACKUP`] by design, not by omission. A backup containing
73/// this keyspace would be an export of keys the VTA promises never to export.
74pub const INTERNAL_KEYS: &str = "internal_keys";
75/// Ephemeral cache (resolver/auth caches).
76pub const CACHE: &str = "cache";
77/// Holder credential vault (third-party secrets stored on this VTA).
78pub const VAULT: &str = "vault";
79/// Persistent runtime service-enable state (`operations::protocol::runtime_state`).
80pub const SERVICE_STATE: &str = "service_state";
81/// Sealed-bootstrap anti-replay nonce log.
82pub const SEALED_NONCES: &str = "sealed_nonces";
83/// In-flight backup-bundle control-plane records.
84pub const BACKUP_BUNDLES: &str = "backup_bundles";
85/// WebVH DID records + `did.jsonl` state.
86pub const WEBVH: &str = "webvh";
87/// In-flight passkey-as-verificationMethod enrolment state.
88pub const PASSKEY_VMS: &str = "passkey_vms";
89/// Persisted protocol-management drain set.
90pub const DRAINS: &str = "drains";
91/// Per-kind previous-config snapshots for fail-forward rollback.
92/// (Historically `operations::protocol::snapshot::KEYSPACE_NAME`.)
93pub const SNAPSHOT: &str = "service_prev_config";
94/// KMS-protected, unencrypted boot keyspace (TEE integrity manifest, etc.).
95pub const BOOTSTRAP: &str = "bootstrap";
96/// Inbound-messaging consent: durable grants + TTL'd pending requests
97/// (`vti_common::consent`). The VTA is the first gate for bridged conversations.
98pub const CONSENT: &str = "consent";
99/// Per-(platform, context) approver bindings — who decides consent and how the
100/// prompt routes (`vti_common::consent::ApproverBinding`).
101pub const CONSENT_APPROVERS: &str = "consent_approvers";
102/// VTA-issued credentials (minted by `vta/credentials/issue/0.1`, revoked by
103/// `vta/credentials/revoke/0.1`). One record per credential keyed `cred:<id>`;
104/// revocation is a tombstone (`revokedAt` set in place), not a delete. Distinct
105/// from [`VAULT`] (which stores credentials the holder *holds*).
106pub const ISSUED_CREDENTIALS: &str = "issued_credentials";
107
108/// Per-context key/value store for AI-agent memory (`vta/memory/{put,list,
109/// delete}/0.1`). One record per `(contextId, key)` pair, keyed
110/// `mem:<contextId>:<key>`; `list` is a `mem:<contextId>:` prefix scan. Durable
111/// user data → in [`BACKED_UP`].
112pub const MEMORY: &str = "memory";
113
114/// A member's MLS group state for each data room they belong to
115/// (`rooms/keys/{welcome,commit,open}`), keyed `room-group:<roomId>`.
116///
117/// **This holds group secrets.** Whoever reads a row can decrypt every record
118/// the group could, up to its epoch — the same class of material as [`KEYS`],
119/// and it inherits the same protection: the KMS storage key in a TEE
120/// deployment, and a trusted data directory outside one.
121///
122/// In [`BACKED_UP`], and that is a decision rather than a default. A member who
123/// restores a VTA without their room groups has lost the ability to read every
124/// sealed room they belong to, with no way to recover it — the group cannot be
125/// re-derived, and rejoining means a fresh invitation from every owner. The
126/// backup already carries the credential vault and the master seed; group state
127/// belongs with them.
128pub const ROOM_GROUPS: &str = "room_groups";
129
130/// Invitation credentials this VTA has consumed by joining a room
131/// (`rooms/keys/welcome`), keyed `room-vic:<credentialId>`.
132///
133/// Separate from [`ROOM_GROUPS`] because it outlives them: a member who leaves a
134/// room discards the group, and the consumed-invitation record must survive that
135/// or the same invitation would let them be re-added without a fresh one. Single
136/// use means single use.
137pub const ROOM_INVITATIONS: &str = "room_invitations";
138
139/// The holder's identity attributes, profiles, bindings and contacts
140/// (`persona/*`) — the fourth store, beside [`VAULT`] (secrets and
141/// credentials), [`MEMORY`] (agent memory) and [`APP_STATE`] (uninterpreted
142/// application JSON).
143///
144/// It is a distinct store because disclosure control is its point, and a
145/// maintainer that cannot read a record cannot decide which of its members may
146/// leave, cannot audit which ones did, and cannot answer "what have I shared
147/// with whom". [`APP_STATE`] promises never to interpret its records, so it
148/// cannot host this; and a namespace there is collision avoidance rather than a
149/// trust boundary, so a compromised application sharing a context could remove
150/// the holder's identity data.
151///
152/// **Two scopes share the keyspace, and the split is a security control rather
153/// than a filing decision.** The pool and profiles are *agent-scoped* — above
154/// every context — so that the correlation index can see the risk it most needs
155/// to report: the same value presented by two personas in two different
156/// contexts, which a per-context index cannot see by construction. Bindings,
157/// contacts and disclosure records are context-scoped, because a persona lives
158/// in a context and so do its counterparties.
159///
160/// Nothing inside a context may read the agent-scoped prefixes. The holder
161/// pushes a materialised projection down; a context never pulls. That is
162/// enforced at dispatch, not here, but the prefix split is what makes the
163/// enforcement expressible.
164///
165/// Agent-scoped:
166///
167/// - `pa:<attributeId>` — one attribute of the pool.
168/// - `pp:<profileId>` — one profile.
169/// - `pxi:<hmac>` — correlation index, keyed by a keyed hash of the value so
170///   exact-match lookup works with no plaintext index over personal data.
171/// - `pxr:<attributeId>:<profileId>` — attribute → profile reverse index, so a
172///   delete can name its referring profiles without scanning every profile.
173///
174/// Context-scoped:
175///
176/// - `pb:<contextId>:<personaDid>` — binding of a profile to a persona DID.
177/// - `pc:<contextId>:<contactId>` — a contact's current revision.
178/// - `pcr:<contextId>:<contactId>:<rev>` — superseded contact revisions,
179///   reference-counted rather than reaped on a flat TTL: a revision behind a
180///   disclosure record is evidence the holder can still be asked to account for.
181/// - `pd:<contextId>:<seq:020>` — append-only disclosure record.
182/// - `plp:<contextId>:<profileId>` — context-local profile (inline entries
183///   only). A *separate prefix*, not a flag on `pp:`, so a context-scoped list
184///   scans a space that structurally cannot contain a pool profile — a filter
185///   bug there would be the same one-line leak the authorization rule exists to
186///   remove.
187/// - `plb:<contextId>:<personaDid>` — binding of a local profile.
188///
189/// The holder's identity is the account → in [`BACKED_UP`]. A restored agent
190/// that came back without it would be an agent that no longer knows who its
191/// holder is.
192pub const PERSONA: &str = "persona";
193
194/// Versioned, namespaced application state (`vta/app-state/{get,put,list,
195/// delete,get-many,put-many}/1.0`) — the third store, beside [`VAULT`] (secrets
196/// and credentials) and [`MEMORY`] (agent memory), for JSON an application owns
197/// and the VTA does not interpret.
198///
199/// Four record shapes share the keyspace, distinguished by prefix:
200///
201/// - `app:<contextId>:<namespace>:<key>` — the record itself. `list` in
202///   snapshot mode is an `app:<contextId>:<namespace>:` prefix scan.
203/// - `appv:<contextId>:<namespace>:<version:020}>` — version index, mapping a
204///   zero-padded counter value to its record key. Change-feed `list` scans this
205///   so it can return changes in version order and paginate over a stable
206///   storage key; a scan-and-sort over the records could do neither.
207/// - `appc:<contextId>:<namespace>` — the namespace's monotonic write counter.
208/// - `appt:<contextId>:<namespace>` — the oldest version still covered by a
209///   retained tombstone, which is what `sinceVersion` is checked against.
210///
211/// Deliberately **not** [`MEMORY`]: clearing an agent's memory has to stay a
212/// safe thing for a user to ask, which it cannot be if account state lives
213/// there. Durable user data — an account's recoverability depends on it — so it
214/// is in [`BACKED_UP`], and a restore that came back without it would defeat
215/// the point of the feature.
216pub const APP_STATE: &str = "app_state";
217
218/// Rego policy modules for the Policy Decision Point (`policy/{upsert,list,
219/// delete,evaluate}`). One `policy::PolicyModule` per id, keyed `policy:<id>`;
220/// the active set is every enabled row, priority-ordered. Durable operator
221/// security config → in [`BACKED_UP`] (a lost policy set would silently drop
222/// enforcement on restore).
223pub const POLICY: &str = "policy";
224
225/// Task-execution consent for the PDP's `requireConsent` disposition: pending
226/// approvals keyed by payload digest, and granted consents a re-submitted task
227/// consumes. Distinct from [`CONSENT`] (messaging-bridge conversation consent).
228/// One `policy::consent::PendingTaskConsent` per `pending:<digest>` and
229/// `policy::consent::TaskConsentGrant` per `grant:<digest>:<requester>`.
230/// Durable operator-facing security state → [`BACKED_UP`].
231pub const TASK_CONSENT: &str = "task_consent";
232
233/// Durable reliable-messaging outbox backing `vti_common::outbox_store::`
234/// `VtiOutboxStore` for the delivery-layer `MessagingService` (D2 P2a
235/// cut-over). Holds `Guaranteed`-delivery outbox entries; dormant in P2a (all
236/// current sends are `BestEffort`) but wired so the drain/confirmation loops
237/// persist across restarts once P2b adds guaranteed VTA pushes. Runtime state,
238/// not backed up.
239pub const OUTBOX: &str = "outbox";
240
241/// Idempotency records for keyed Trust Tasks — one row per
242/// `(actor, idempotency-key)`, holding the request digest and, for tasks whose
243/// response may be replayed, the original response. Lets a client's retry of a
244/// lost reply converge on the first execution instead of producing a second
245/// durable effect.
246///
247/// Persistent rather than in-memory (unlike the `(actor, envelope-id)` replay
248/// cache it sits beside) because the window that matters is exactly the one a
249/// restart falls inside: the VTA processed the request, the reply was lost, and
250/// the client is still retrying. Swept on TTL by
251/// `vta_sweepers::idempotency_sweeper`. Runtime state, not backed up.
252pub const IDEMPOTENCY: &str = "idempotency";
253
254/// Every production keyspace. Partitioned by [`BACKED_UP`] +
255/// [`EXCLUDED_FROM_BACKUP`]; the [`tests::backup_partition_is_total`] guard
256/// asserts the partition stays exhaustive so a newly-added keyspace can't be
257/// silently omitted from the backup decision.
258pub const ALL: &[&str] = &[
259    INTERNAL_KEYS,
260    KEYS,
261    SESSIONS,
262    ACL,
263    CONTEXTS,
264    DID_TEMPLATES,
265    AUDIT,
266    AUDIT_KEY,
267    IMPORTED_SECRETS,
268    CACHE,
269    VAULT,
270    SERVICE_STATE,
271    SEALED_NONCES,
272    BACKUP_BUNDLES,
273    WEBVH,
274    PASSKEY_VMS,
275    DRAINS,
276    SNAPSHOT,
277    BOOTSTRAP,
278    CONSENT,
279    CONSENT_APPROVERS,
280    ISSUED_CREDENTIALS,
281    MEMORY,
282    ROOM_GROUPS,
283    ROOM_INVITATIONS,
284    APP_STATE,
285    PERSONA,
286    POLICY,
287    TASK_CONSENT,
288    OUTBOX,
289    IDEMPOTENCY,
290];
291
292/// Keyspaces whose contents a full `export_backup` captures (as typed
293/// collections — see `operations::backup`).
294pub const BACKED_UP: &[&str] = &[
295    KEYS,
296    ACL,
297    CONTEXTS,
298    AUDIT,
299    // Without the keys, a restored audit log still verifies as a chain and
300    // still says what happened, but no entry can be checked against a
301    // candidate identifier again — the commitments become opaque. The key is
302    // generated rather than derived, so nothing else reproduces it.
303    AUDIT_KEY,
304    IMPORTED_SECRETS,
305    WEBVH,
306    CONSENT,
307    CONSENT_APPROVERS,
308    // Durable agent memory is user data and must survive a restore.
309    MEMORY,
310    ROOM_GROUPS,
311    ROOM_INVITATIONS,
312    // Application state IS the user's account for a consumer built on it —
313    // labels, relationships, contacts, join history. A restore that came back
314    // without it would return a VTA whose applications no longer recognise
315    // their own data, which is the failure the store exists to prevent.
316    APP_STATE,
317    // The holder's own identity — attributes, profiles, bindings, contacts.
318    // A restore that came back without it would return an agent that no longer
319    // knows who its holder is, which is most of what the restore was for.
320    PERSONA,
321    // Operator security policy — must survive a restore, else enforcement
322    // silently reverts to whatever defaults boot-install provides.
323    POLICY,
324    // Task-consent grants are durable authorizations a re-submitted task
325    // consumes; losing them on restore would strand in-flight approvals.
326    TASK_CONSENT,
327];
328
329/// Keyspaces deliberately **not** in a backup.
330///
331/// Most are ephemeral / runtime / re-derivable: [`SESSIONS`], [`CACHE`],
332/// [`SEALED_NONCES`], [`SERVICE_STATE`], [`BACKUP_BUNDLES`], [`PASSKEY_VMS`],
333/// [`DRAINS`], [`SNAPSHOT`], [`BOOTSTRAP`]. [`DID_TEMPLATES`] and [`VAULT`]
334/// hold durable operator/holder state and are **known backup gaps** — a
335/// backup-fidelity follow-up should move them into [`BACKED_UP`], not leave
336/// them silently dropped.
337pub const EXCLUDED_FROM_BACKUP: &[&str] = &[
338    // Non-extractable internal signing keys. Excluding them is the feature:
339    // a backup that carried them would export keys the VTA guarantees never
340    // to export, and restoring one elsewhere would silently clone a signer.
341    INTERNAL_KEYS,
342    SESSIONS,
343    DID_TEMPLATES,
344    CACHE,
345    VAULT,
346    SERVICE_STATE,
347    SEALED_NONCES,
348    BACKUP_BUNDLES,
349    PASSKEY_VMS,
350    DRAINS,
351    SNAPSHOT,
352    BOOTSTRAP,
353    // Durable VTA-issued holder credentials. Like [`VAULT`], a known backup
354    // gap — a backup-fidelity follow-up should move it into [`BACKED_UP`].
355    ISSUED_CREDENTIALS,
356    // Reliable-messaging outbox: runtime delivery state, re-driven from live
357    // sends, not part of a state backup.
358    OUTBOX,
359    // Trust-Task idempotency records. Short-lived by construction (a retry
360    // window, not durable state) and scoped to the VTA that served the original
361    // request — restoring one elsewhere would claim to have already performed
362    // operations that instance never did.
363    IDEMPOTENCY,
364];
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369    use std::collections::BTreeSet;
370
371    /// The backup partition must be total and disjoint: every production
372    /// keyspace is either backed up or explicitly excluded. Adding a keyspace
373    /// to [`ALL`] without classifying it fails here — that's the point.
374    #[test]
375    fn backup_partition_is_total() {
376        let all: BTreeSet<&str> = ALL.iter().copied().collect();
377        let backed: BTreeSet<&str> = BACKED_UP.iter().copied().collect();
378        let excluded: BTreeSet<&str> = EXCLUDED_FROM_BACKUP.iter().copied().collect();
379
380        assert_eq!(all.len(), ALL.len(), "ALL has a duplicate");
381        assert!(
382            backed.is_disjoint(&excluded),
383            "a keyspace is both backed up and excluded: {:?}",
384            backed.intersection(&excluded).collect::<Vec<_>>()
385        );
386        let union: BTreeSet<&str> = backed.union(&excluded).copied().collect();
387        assert_eq!(
388            union, all,
389            "backup partition is not exhaustive — every keyspace in ALL must be in \
390             exactly one of BACKED_UP / EXCLUDED_FROM_BACKUP"
391        );
392    }
393}
394
395// ---------------------------------------------------------------------------
396// What a DID deletion means for each keyspace
397// ---------------------------------------------------------------------------
398
399/// What happens to a keyspace's DID-keyed contents when that DID is deleted.
400///
401/// Deleting a DID is not one cleanup. It is four different relationships, and
402/// treating them alike gets one of them wrong in a way nobody notices until it
403/// matters:
404///
405/// * things the DID **owns** go with it;
406/// * things that **name it as a subject of authorization** must go with it, or
407///   they become authority for an identity that no longer resolves;
408/// * things that **depend on it to function** must *stop* the deletion, because
409///   cascading would silently break them;
410/// * credentials the VTA **issued** cannot be deleted at all — copies exist
411///   elsewhere — so the only honest action is revocation.
412///
413/// # Why this is an enum and not a list in a function
414///
415/// The failure mode is not getting today's answers wrong. It is a keyspace
416/// added next quarter that nobody classifies, whose rows then quietly outlive
417/// the DID they belong to. [`ALL`] is already pinned by a census test for the
418/// backup partition, for exactly the same reason; this rides the same rail, so
419/// "we forgot" is a red test rather than an orphan found months later in a log.
420///
421/// The classifications below are judgements and several are arguable. That is
422/// fine — the point of the census is to force the question to be asked, not to
423/// claim these answers are the last word.
424#[derive(Debug, Clone, Copy, PartialEq, Eq)]
425pub enum DidDeleteEffect {
426    /// Rows belonging to the DID are removed with it.
427    Cascade,
428    /// A row referencing the DID **blocks** the deletion: something still in
429    /// use would break. Refused, never forced — the operator is told what to
430    /// unpick first.
431    Blocks,
432    /// Rows cannot be removed, because the VTA is not the only holder. They
433    /// are revoked instead.
434    Revoke,
435    /// Nothing here is keyed to a DID.
436    Unrelated,
437}
438
439/// The effect a DID deletion has on `keyspace`, or `None` if the name is not a
440/// keyspace this build knows.
441///
442/// Every entry in [`ALL`] is classified — see `did_delete_census` in this
443/// module's tests.
444#[must_use]
445pub const fn did_delete_effect(keyspace: &str) -> Option<DidDeleteEffect> {
446    use DidDeleteEffect::*;
447    // `const fn` cannot match on `&str`, so this is a byte-slice match.
448    Some(match keyspace.as_bytes() {
449        // ---- Owned by the DID -------------------------------------------
450        // Key material derived under it, its own log, its advertised name.
451        b"keys" | b"internal_keys" | b"imported_secrets" | b"webvh" => Cascade,
452        // Resolution + protocol caches keyed by DID: stale the moment it goes.
453        b"cache" | b"outbox" => Cascade,
454
455        // ---- Names the DID as a subject of authorization -----------------
456        // An ACL entry outliving its DID is the worst of the orphans: live
457        // authority for an identity that can no longer be resolved or rotated.
458        // The VTC learned this the expensive way (#1194, #1196).
459        b"acl" | b"sessions" | b"passkey_vms" => Cascade,
460        // Consent state and the vault are held *for* a holder; with the holder
461        // gone they are unreachable by anyone.
462        b"consent" | b"task_consent" | b"vault" => Cascade,
463        // Per-DID application state the VTA stores on a holder's behalf.
464        b"app_state" | b"memory" => Cascade,
465        // A member's room groups, and the invitations they were joined under.
466        // Held for the holder in the same sense the vault is: the group state
467        // decrypts a room *that member* belongs to, and with the member gone
468        // nobody can use it or ever will again. Keeping it would leave group
469        // secrets on disk outliving the only party they were for.
470        //
471        // The consumed-invitation records go with them and not before: while
472        // the member exists, a consumed invitation MUST outlive the group it
473        // let them join, or leaving a room would make the same invitation work
474        // twice. Single use means single use, and the record is the only thing
475        // that remembers.
476        b"room_groups" | b"room_invitations" => Cascade,
477
478        // Persona is two scopes in one keyspace and only one half is DID-keyed,
479        // which the per-keyspace enum cannot say — so it is said here. The
480        // context-scoped rows DO belong to a DID: a binding is keyed by the
481        // persona DID, and a contact records the persona that knows it. Those
482        // cascade, because a binding for a DID that can no longer be resolved is
483        // the ACL orphan again in a different keyspace. The agent-scoped rows —
484        // the attribute pool, the profiles, and their indexes — are keyed to no
485        // DID and survive, which is correct: a profile may be bound to several
486        // personas, and deleting one persona must not destroy facts the holder
487        // still presents through another.
488        b"persona" => Cascade,
489
490        // ---- Depends on the DID to function ------------------------------
491        // A context whose `did` is this one, a DID named in an advertised
492        // service entry (or its rollback snapshot), a policy or approver set
493        // that names it. Cascading any of these breaks something that is still
494        // in use; refusing tells the operator what to unpick.
495        b"contexts" | b"service_state" | b"service_prev_config" => Blocks,
496        b"policy" | b"consent_approvers" => Blocks,
497
498        // ---- Cannot be deleted, only revoked -----------------------------
499        // Third parties hold copies. Deleting our record achieves nothing but
500        // losing our ability to revoke it.
501        b"issued_credentials" => Revoke,
502
503        // ---- Not keyed to a DID ------------------------------------------
504        // The audit log is deliberately here: it is append-only, and the record
505        // that a DID was deleted is the one thing that must survive deleting it.
506        b"audit" | b"audit_key" => Unrelated,
507        b"did_templates" | b"sealed_nonces" | b"backup_bundles" => Unrelated,
508        b"drains" | b"bootstrap" | b"idempotency" => Unrelated,
509
510        _ => return None,
511    })
512}
513
514#[cfg(test)]
515mod did_delete_tests {
516    use super::*;
517
518    /// Every keyspace must have an answer to "what happens to this when a DID
519    /// is deleted".
520    ///
521    /// This is the whole point of the classification. Adding a keyspace is
522    /// easy; remembering that its rows might outlive the DID they belong to is
523    /// not, and nothing about adding one prompts the question. This test asks
524    /// it, once, at the only moment anyone is looking.
525    ///
526    /// A new keyspace fails here until it is classified. `Unrelated` is a
527    /// perfectly good answer — but it has to be a chosen one.
528    #[test]
529    fn every_keyspace_is_classified_for_did_deletion() {
530        let unclassified: Vec<&str> = ALL
531            .iter()
532            .copied()
533            .filter(|ks| did_delete_effect(ks).is_none())
534            .collect();
535        assert!(
536            unclassified.is_empty(),
537            "these keyspaces have no DID-deletion effect declared: {unclassified:?}\n\
538             Add them to `did_delete_effect`. `Unrelated` is a fine answer if \
539             nothing in the keyspace is keyed to a DID — but it must be chosen, \
540             not defaulted."
541        );
542    }
543
544    /// An unknown name is not silently `Unrelated`. The distinction matters:
545    /// `None` means "this build does not know that keyspace", and answering
546    /// `Unrelated` to it would let a typo read as "nothing to clean up".
547    #[test]
548    fn an_unknown_keyspace_has_no_effect_rather_than_a_harmless_one() {
549        assert_eq!(did_delete_effect("not_a_keyspace"), None);
550        assert_eq!(did_delete_effect(""), None);
551    }
552
553    /// The credential keyspace must never be classified `Cascade`.
554    ///
555    /// Pinned explicitly because it is the one that looks most like a cascade
556    /// and is not: the VTA is not the only holder of what it issued, so
557    /// deleting our record destroys the ability to revoke it while leaving
558    /// every copy in the wild valid forever. That is the exact residue an ACL
559    /// revoke left behind on the VTC.
560    #[test]
561    fn issued_credentials_are_revoked_never_deleted() {
562        assert_eq!(
563            did_delete_effect(ISSUED_CREDENTIALS),
564            Some(DidDeleteEffect::Revoke)
565        );
566    }
567
568    /// The audit log must survive the deletion it records.
569    #[test]
570    fn the_audit_log_is_never_cascaded() {
571        assert_eq!(did_delete_effect(AUDIT), Some(DidDeleteEffect::Unrelated));
572    }
573}