Skip to main content

vti_common/audit/
event.rs

1//! [`AuditEvent`] — the tagged enum of every audit-log variant.
2//!
3//! Ships the **Phase-0 vocabulary** matching spec §11.4. Phase-1+
4//! variants land alongside their owning features (join requests,
5//! members, policies, registry, VRC, etc.) and follow the same
6//! pattern: one variant per semantically distinct event, with a
7//! purpose-built data struct.
8//!
9//! ## Wire contract
10//!
11//! - Tagged form `#[serde(tag = "type", content = "data")]` so
12//!   external consumers (SIEM, later webhooks) discriminate on the
13//!   `type` field. **Variant identifiers are part of the wire
14//!   contract — don't rename them without bumping
15//!   `EVENT_VERSION`.**
16//! - Data structs use `#[serde(rename_all = "camelCase")]` for
17//!   downstream tooling friendliness. Field names are also wire
18//!   contract.
19//!
20//! ## Sensitive-field redaction
21//!
22//! [`ConfigChange::redact_if`] walks a [`ConfigChangedData`] and
23//! masks `old_value` / `new_value` for any key matched by the caller-
24//! supplied sensitivity predicate. The emitter (config endpoint
25//! handlers, M0.8) calls this **before** persisting the event so
26//! sensitive values never reach the audit keyspace in cleartext.
27
28use chrono::{DateTime, Utc};
29use serde::{Deserialize, Serialize};
30use serde_json::Value;
31
32use super::key_store::RotationReason;
33
34/// Marker used in place of redacted config values. Distinguishable
35/// from a JSON null / empty string by callers introspecting an
36/// archived audit row.
37pub const REDACTED_MARKER: &str = "<redacted>";
38
39// ---------------------------------------------------------------------------
40// AuditEvent
41// ---------------------------------------------------------------------------
42
43/// Audit-event payload. Tagged on `type` with the variant name and
44/// the variant's data under `data`. Phase-0 vocabulary only;
45/// Phase-1+ adds variants alongside the features that emit them.
46#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
47#[serde(tag = "type", content = "data")]
48pub enum AuditEvent {
49    /// Bootstrap completed — the first admin DID was written into the
50    /// ACL and the install carve-out was permanently closed.
51    CommunityInstalled(CommunityInstalledData),
52
53    /// `vtc admin emergency-bootstrap` was invoked with a valid
54    /// master-seed mnemonic, re-opening the install carve-out exactly
55    /// once. Loud event — surfaced prominently in diagnostics on next
56    /// daemon start so a forgotten emergency action is impossible to
57    /// miss.
58    EmergencyBootstrapInvoked(EmergencyBootstrapData),
59
60    /// A passkey was registered against an admin DID (initial enrol
61    /// at install **or** a subsequent additional-device enrolment).
62    AdminPasskeyRegistered(AdminPasskeyData),
63
64    /// A passkey was revoked from an admin DID. The CAS check that
65    /// refuses to leave zero passkeys runs *before* the event is
66    /// emitted, so any persisted `AdminPasskeyRevoked` leaves at
67    /// least one passkey behind.
68    AdminPasskeyRevoked(AdminPasskeyData),
69
70    /// One or more runtime configuration keys were modified via
71    /// `PATCH /v1/admin/config`. Per-key sensitivity is honoured —
72    /// values for keys flagged sensitive are redacted via
73    /// [`ConfigChange::redact_if`] before persistence.
74    ConfigChanged(ConfigChangedData),
75
76    /// `POST /v1/admin/config/reload` applied hot-reloadable settings
77    /// in-place. Lists which keys actually re-applied (a key that
78    /// was unchanged-or-already-active doesn't appear).
79    ConfigReloaded(ConfigReloadedData),
80
81    /// `POST /v1/admin/config/restart` initiated graceful shutdown.
82    /// Emitted **before** the process exits so the next-boot replay
83    /// can correlate the restart with the prior config patches that
84    /// triggered it.
85    RestartRequested(RestartRequestedData),
86
87    /// `PUT /v1/community/profile` updated one or more profile
88    /// fields. Records which fields changed by name; the values
89    /// themselves stay out of the audit log (profile data isn't
90    /// security-sensitive by nature, but keeping the event small
91    /// is operator-friendly).
92    CommunityProfileUpdated(CommunityProfileUpdatedData),
93
94    /// The community `audit_key` was rotated. Emitted under the
95    /// **new** key (the rotation itself is what creates the new
96    /// epoch), so an investigator can find the row by querying the
97    /// `audit_by_type` index without needing to walk the prior
98    /// epoch.
99    AuditKeyRotated(AuditKeyRotatedData),
100
101    /// `PATCH /v1/members/{did}` updated profile or non-role
102    /// metadata on a member's record. Lists the field names that
103    /// changed; values stay out of the envelope.
104    MemberUpdated(MemberUpdatedData),
105
106    /// `PATCH /v1/members/{did}` reassigned the member's role.
107    /// Distinct event from `MemberUpdated` because role changes
108    /// are security-significant — SIEM filters key on this
109    /// variant separately. Admin promotion uses
110    /// [`AdminPromoted`] instead (spec §10.4 keeps the two
111    /// paths separate).
112    RoleChanged(RoleChangedData),
113
114    /// A role change reached `admin`. Spec §10.4 makes this its own
115    /// variant (distinct from `RoleChanged`) so SIEM rules can target
116    /// it; admin elevation is the highest-privilege grant the
117    /// community emits.
118    ///
119    /// The authorising user-verification is recorded on its own
120    /// [`AuthSteppedUp`](Self::AuthSteppedUp) row, joined by
121    /// `authorising_session_id`. See [`AdminPromotedData`].
122    AdminPromoted(AdminPromotedData),
123
124    /// A passkey step-up ceremony elevated a session — the
125    /// user-verification event that authorises whatever privileged
126    /// operation follows inside the elevation window.
127    ///
128    /// Emitted where the gesture actually happens, which since the
129    /// promote-to-admin fold is not the same request as the operation
130    /// it authorises. Recording it separately is what keeps the chain
131    /// of authority auditable across an API split: this row names the
132    /// credential, the operation's row names the session.
133    AuthSteppedUp(AuthSteppedUpData),
134
135    /// `POST /v1/join-requests` (REST or DIDComm) accepted a
136    /// well-formed submission and persisted it as `Pending`. The
137    /// actor on this event is the applicant DID — they're the
138    /// principal, even though the daemon's authenticated identity
139    /// did not vouch for them.
140    JoinRequestSubmitted(JoinRequestData),
141
142    /// An admin / moderator approved a pending join request via
143    /// `POST /v1/join-requests/{id}/approve`. Always paired with a
144    /// `MemberAdded` emission in the same transaction (the
145    /// approve flow writes the ACL + Member rows atomically).
146    JoinRequestApproved(JoinRequestData),
147
148    /// An admin / moderator rejected a pending join request. The
149    /// `reason` field is operator-supplied and may be empty.
150    JoinRequestRejected(JoinRequestRejectedData),
151
152    /// New member row written. Companion event to
153    /// `JoinRequestApproved` — the latter is what an audit
154    /// query for "who approved this" matches, the former is
155    /// what "when did <did> join" matches. Spec §10.1.
156    MemberAdded(MemberAddedData),
157
158    /// Member row removed (or anonymised) per spec §10.2. Spec §5
159    /// `Disposition` decides whether the row is purged outright,
160    /// tombstoned with the DID retained, or kept historical.
161    MemberRemoved(MemberRemovedData),
162
163    /// A member discharged the `reciprocate_vmc` obligation: they
164    /// counter-signed the issued VMC with a member-issued reciprocal
165    /// VC, completing the bidirectional DTG membership edge
166    /// (`join-requests/accept/1.0`). The audit envelope's
167    /// `target_did` carries the member.
168    MembershipReciprocated(MembershipReciprocatedData),
169
170    /// `POST /v1/policies` accepted an upload, compiled the source,
171    /// and persisted a new revision. The row is **not yet active** —
172    /// activation is a separate event. Spec §7.1; Phase 2 M2.3.
173    PolicyUploaded(PolicyUploadedData),
174
175    /// `POST /v1/policies/{id}/activate` flipped the active pointer
176    /// for a purpose. Carries the predecessor's id so a forensic
177    /// audit can chain backwards through revisions without scanning
178    /// the whole `policies:` keyspace. Spec §7.1; Phase 2 M2.3.
179    PolicyActivated(PolicyActivatedData),
180
181    /// A new VMC was minted (join-approve or renewal). Spec §6.1.
182    VmcIssued(CredentialIssuedData),
183
184    /// A new role VEC was minted (join-approve, renewal, or role
185    /// change). Spec §6.1.
186    VecIssued(CredentialIssuedData),
187
188    /// `POST /v1/members/me/renew` re-minted the member's VMC +
189    /// role VEC. Spec §6.3. `personhood_changed` flips when the
190    /// renewal's `personhood.rego` re-eval produced a different
191    /// flag than the prior VMC.
192    MembershipRenewed(MembershipRenewedData),
193
194    /// A status-list bit was flipped (revocation / suspension).
195    /// Spec §6.2.
196    StatusListFlipped(StatusListFlippedData),
197
198    /// A member rotated to a fresh DID. The audit envelope's
199    /// `actor_did` is the **new** DID (it's the principal going
200    /// forward); the prior DID lives in the data struct. Spec
201    /// §10.5; Phase 2 M2.15.
202    DidRotated(DidRotatedData),
203
204    /// The daemon's trust-registry reachability state flipped
205    /// (`active` ↔ `degraded`). Spec §8.1; Phase 3 M3.2. SIEM
206    /// filters key on this to alert when the registry connection
207    /// drops or recovers.
208    RegistryStatusChanged(RegistryStatusChangedData),
209
210    /// A `MembershipSyncer` job completed successfully against
211    /// the registry. Spec §8.3; Phase 3 M3.4.
212    RegistrySyncSucceeded(RegistrySyncOutcomeData),
213
214    /// A `MembershipSyncer` job flipped to the `Failed` state
215    /// after exhausting its retry budget. Spec §8.3 calls these
216    /// out for operator attention — failed `Purge` jobs are
217    /// silent privacy regressions. Phase 3 M3.4.
218    RegistrySyncFailed(RegistrySyncOutcomeData),
219
220    /// A member-initiated `Purge` (RTBF) bypassed the active
221    /// `registry.rego.min_disposition` floor. Spec §8.2 calls
222    /// these out: RTBF always overrides the policy envelope.
223    /// The audit envelope's `actor_did_hash` is the HMAC-hashed
224    /// identifier per §11.1; the `actor_did_plain` field is
225    /// `None` for these envelopes (privacy-preserving by
226    /// construction). Phase 3 M3.6.
227    RegistryRecordPolicyOverride(RegistryRecordPolicyOverrideData),
228
229    /// A cross-community session was minted (or denied). Spec
230    /// §8.4; Phase 3 M3.10. The `outcome` field discriminates
231    /// `minted` from `denied`; the `reason` is populated only
232    /// on `denied` and carries one of [`RecognitionError`]'s
233    /// stable reason codes
234    /// (`issuer-key-unresolved` / `proof-invalid` /
235    /// `status-list-failed` / `issuer-not-recognised` /
236    /// `registry-unreachable` / `validity-window` / `malformed`
237    /// / `role-mapping-denied`).
238    CrossCommunitySessionMinted(CrossCommunitySessionMintedData),
239
240    // ─── Phase 4 lifecycle ─────────────────────────────────
241    /// A member published a self-issued Verifiable Recognition
242    /// Credential (VRC) — a trust edge `issuer-member → subject-
243    /// member` per spec §5.4 + §6.1. Phase 4 M4.6. The actor is
244    /// the issuer; the target is the subject DID.
245    VrcPublished(VrcPublishedData),
246
247    /// A VRC was revoked — either by the original issuer or by
248    /// an admin acting on behalf of the community. Phase 4 M4.6.
249    /// Per D7, VRCs carry no `credentialStatus`; revocation is
250    /// row deletion in the local `relationships:` keyspace.
251    VrcRevoked(VrcRevokedData),
252
253    /// A member's personhood flag was asserted true via
254    /// `POST /v1/members/{did}/personhood/assert`. Phase 4
255    /// M4.3. The actor is the asserter (admin or issuer); the
256    /// target is the member. Per D2 review (VP-only assert),
257    /// the presented evidence is verified at assert time and
258    /// discarded — no `evidence_sha256` field on this envelope.
259    PersonhoodAsserted(PersonhoodAssertedData),
260
261    /// A member's personhood flag was revoked. Phase 4 M4.4 +
262    /// M4.2.2. The `reason` discriminator pins which of the
263    /// three triggers fired: `"admin"` (admin `DELETE`),
264    /// `"self"` (member `DELETE`), or `"renewal-policy"`
265    /// (renewal-time policy downgrade per D5 review's
266    /// `downgrade` arm).
267    PersonhoodRevoked(PersonhoodRevokedData),
268
269    /// A custom (non-role) endorsement credential was issued
270    /// by an issuer or admin. Phase 4 M4.8. Per D8 review, the
271    /// credential carries a `credentialStatus` entry on the
272    /// shared `Revocation` status list — `status_list_index`
273    /// records the allocated slot.
274    CustomEndorsementIssued(CustomEndorsementIssuedData),
275
276    /// A custom endorsement was revoked. Phase 4 M4.8. Flips
277    /// the `Revocation` status-list bit at the credential's
278    /// `status_list_index`. Paired with a
279    /// `StatusListFlipped { purpose: "revocation", index,
280    /// revoked: true }` envelope (existing variant) so the
281    /// status-list audit surface stays uniform.
282    CustomEndorsementRevoked(CustomEndorsementRevokedData),
283
284    /// An operator registered a new custom endorsement type
285    /// via `POST /v1/endorsement-types`. Phase 4 M4.8.1 (D4
286    /// review). The actor is the admin; the `type_uri` field
287    /// records what was registered.
288    EndorsementTypeRegistered(EndorsementTypeRegisteredData),
289
290    /// An operator deleted a custom endorsement type via
291    /// `DELETE /v1/endorsement-types/{uri}`. Phase 4 M4.8.1.
292    /// The registry refuses deletion when at least one live
293    /// endorsement of this type still exists; this envelope
294    /// only fires after a successful delete.
295    EndorsementTypeDeleted(EndorsementTypeDeletedData),
296
297    /// `PUT /v1/website/files/{path}` succeeded. Phase 5 M5.5.2.
298    /// Records the path + size + SHA-256 of the new content so the
299    /// audit log carries enough material to reconstruct a deploy
300    /// without persisting the full file body.
301    WebsiteFileWritten(WebsiteFileWrittenData),
302
303    /// `DELETE /v1/website/files/{path}` succeeded. Phase 5
304    /// M5.5.2. Records the path; no content digest because the
305    /// file no longer exists.
306    WebsiteFileDeleted(WebsiteFileDeletedData),
307
308    /// `POST /v1/website/deploy` succeeded. Phase 5 M5.5.3.
309    /// Captures the bundle's SHA-256, byte size, target deploy
310    /// mode, and (managed mode only) the new generation number
311    /// + how many old generations were pruned.
312    WebsiteBundleDeployed(WebsiteBundleDeployedData),
313
314    /// `POST /v1/website/rollback/{gen}` succeeded. Phase 5
315    /// M5.5.4. Managed mode only. Records the symlink swap so
316    /// the audit log surfaces which generation served before vs.
317    /// after.
318    WebsiteGenerationRolledBack(WebsiteGenerationRolledBackData),
319
320    /// Emitted exactly once at daemon boot when the embedded
321    /// admin UX (`admin-ui` cargo feature) is enabled. Captures
322    /// the SHA-256 of the baked `index.html` so an operator can
323    /// correlate which build of the admin SPA is currently
324    /// serving. Phase 5 M5.7.2.
325    AdminUiServed(AdminUiServedData),
326
327    /// An ACL entry was created — a DID was granted a community role
328    /// (`POST /v1/acl`). Authorization grants are the highest-value
329    /// forensic events: this records who now holds what authority,
330    /// scoped to which contexts, until when. Envelope `target_did` is
331    /// the granted DID.
332    AclGranted(AclChangeData),
333
334    /// An existing ACL entry was modified (`PATCH /v1/acl/{did}`) —
335    /// role / contexts / expiry changed. Envelope `target_did` is the
336    /// affected DID; the payload carries the new state.
337    AclUpdated(AclChangeData),
338
339    /// An ACL entry was deleted (`DELETE /v1/acl/{did}`) — the DID lost
340    /// its community authority. Envelope `target_did` is the revoked DID.
341    AclRevoked(AclRevokedData),
342
343    /// A Verifiable Invitation Credential (VIC) was issued
344    /// (`POST /v1/invitations`). Records the invitee, granted role, and
345    /// the revocation slot so the credential's whole lifecycle is
346    /// traceable. Envelope `target_did` is the invitee.
347    InvitationIssued(InvitationIssuedData),
348
349    /// An invitation was revoked (`DELETE /v1/invitations/{id}`) — its
350    /// revocation bit flipped. Envelope `target_did` is the invitee.
351    InvitationRevoked(InvitationRevokedData),
352
353    /// One or more authenticated sessions were revoked by an admin
354    /// (`DELETE /v1/auth/sessions/{id}` or `?did=`). Cutting off access
355    /// is security-relevant and must be attributable. Envelope
356    /// `target_did` is the session owner (when revoking by DID).
357    SessionRevoked(SessionRevokedData),
358
359    /// A principal ended their **own** session (`POST
360    /// /v1/auth/sign-out`).
361    ///
362    /// Deliberately distinct from [`Self::SessionRevoked`] rather than
363    /// a flag on it: voluntary sign-out is routine, admin-forced
364    /// revocation is a security signal, and a SIEM rule that has to
365    /// tell them apart by comparing actor to target will get it wrong
366    /// the first time an admin revokes their own session. Same
367    /// reasoning as [`Self::AdminPromoted`] versus [`Self::RoleChanged`].
368    SignedOut(SignedOutData),
369
370    /// An encrypted state backup was exported (`POST /v1/backup/export`).
371    BackupExported(BackupData),
372
373    /// An encrypted state backup was imported, restoring community state
374    /// (`POST /v1/backup/import`). A full-state restore is among the most
375    /// sensitive operations — recorded so a restore is never silent.
376    BackupImported(BackupData),
377
378    /// An admin onboarding invite was minted (`POST /v1/admin/invites`),
379    /// optionally pre-creating an ACL grant. Envelope `target_did` is the
380    /// invited admin DID (when bound to one).
381    AdminInviteCreated(AdminInviteData),
382
383    /// An admin onboarding invite was revoked (`DELETE /v1/admin/invites/{jti}`).
384    AdminInviteRevoked(AdminInviteData),
385
386    /// A credential schema or accepts-criterion was registered
387    /// (`POST /v1/schemas` or `/v1/schemas/accepts`) — what the community
388    /// accepts/recognises changed.
389    SchemaRegistered(SchemaChangeData),
390
391    /// A credential schema or accepts-criterion was deleted
392    /// (`DELETE /v1/schemas/{type_uri}` or `/v1/schemas/accepts/{id}`).
393    SchemaDeleted(SchemaChangeData),
394}
395
396impl AuditEvent {
397    /// The serde tag for this variant — the same string that appears as
398    /// `{"type": ...}` on the stored envelope.
399    ///
400    /// This is what the canonical `audit/list` Trust Task exposes as
401    /// `action`, and what `audit/list`'s `action` filter matches on, so
402    /// it must stay identical to the serialized tag. Kept as an explicit
403    /// match (rather than round-tripping through `serde_json`) so listing
404    /// does not pay a serialization per row just to learn the name.
405    pub fn variant_name(&self) -> &'static str {
406        match self {
407            Self::CommunityInstalled(..) => "CommunityInstalled",
408            Self::EmergencyBootstrapInvoked(..) => "EmergencyBootstrapInvoked",
409            Self::AdminPasskeyRegistered(..) => "AdminPasskeyRegistered",
410            Self::AdminPasskeyRevoked(..) => "AdminPasskeyRevoked",
411            Self::ConfigChanged(..) => "ConfigChanged",
412            Self::ConfigReloaded(..) => "ConfigReloaded",
413            Self::RestartRequested(..) => "RestartRequested",
414            Self::CommunityProfileUpdated(..) => "CommunityProfileUpdated",
415            Self::AuditKeyRotated(..) => "AuditKeyRotated",
416            Self::MemberUpdated(..) => "MemberUpdated",
417            Self::RoleChanged(..) => "RoleChanged",
418            Self::AdminPromoted(..) => "AdminPromoted",
419            Self::AuthSteppedUp(..) => "AuthSteppedUp",
420            Self::JoinRequestSubmitted(..) => "JoinRequestSubmitted",
421            Self::JoinRequestApproved(..) => "JoinRequestApproved",
422            Self::JoinRequestRejected(..) => "JoinRequestRejected",
423            Self::MemberAdded(..) => "MemberAdded",
424            Self::MemberRemoved(..) => "MemberRemoved",
425            Self::MembershipReciprocated(..) => "MembershipReciprocated",
426            Self::PolicyUploaded(..) => "PolicyUploaded",
427            Self::PolicyActivated(..) => "PolicyActivated",
428            Self::VmcIssued(..) => "VmcIssued",
429            Self::VecIssued(..) => "VecIssued",
430            Self::MembershipRenewed(..) => "MembershipRenewed",
431            Self::StatusListFlipped(..) => "StatusListFlipped",
432            Self::DidRotated(..) => "DidRotated",
433            Self::RegistryStatusChanged(..) => "RegistryStatusChanged",
434            Self::RegistrySyncSucceeded(..) => "RegistrySyncSucceeded",
435            Self::RegistrySyncFailed(..) => "RegistrySyncFailed",
436            Self::RegistryRecordPolicyOverride(..) => "RegistryRecordPolicyOverride",
437            Self::CrossCommunitySessionMinted(..) => "CrossCommunitySessionMinted",
438            Self::VrcPublished(..) => "VrcPublished",
439            Self::VrcRevoked(..) => "VrcRevoked",
440            Self::PersonhoodAsserted(..) => "PersonhoodAsserted",
441            Self::PersonhoodRevoked(..) => "PersonhoodRevoked",
442            Self::CustomEndorsementIssued(..) => "CustomEndorsementIssued",
443            Self::CustomEndorsementRevoked(..) => "CustomEndorsementRevoked",
444            Self::EndorsementTypeRegistered(..) => "EndorsementTypeRegistered",
445            Self::EndorsementTypeDeleted(..) => "EndorsementTypeDeleted",
446            Self::WebsiteFileWritten(..) => "WebsiteFileWritten",
447            Self::WebsiteFileDeleted(..) => "WebsiteFileDeleted",
448            Self::WebsiteBundleDeployed(..) => "WebsiteBundleDeployed",
449            Self::WebsiteGenerationRolledBack(..) => "WebsiteGenerationRolledBack",
450            Self::AdminUiServed(..) => "AdminUiServed",
451            Self::AclGranted(..) => "AclGranted",
452            Self::AclUpdated(..) => "AclUpdated",
453            Self::AclRevoked(..) => "AclRevoked",
454            Self::InvitationIssued(..) => "InvitationIssued",
455            Self::InvitationRevoked(..) => "InvitationRevoked",
456            Self::SessionRevoked(..) => "SessionRevoked",
457            Self::SignedOut(..) => "SignedOut",
458            Self::BackupExported(..) => "BackupExported",
459            Self::BackupImported(..) => "BackupImported",
460            Self::AdminInviteCreated(..) => "AdminInviteCreated",
461            Self::AdminInviteRevoked(..) => "AdminInviteRevoked",
462            Self::SchemaRegistered(..) => "SchemaRegistered",
463            Self::SchemaDeleted(..) => "SchemaDeleted",
464        }
465    }
466}
467
468// ---------------------------------------------------------------------------
469// Variant data structs
470// ---------------------------------------------------------------------------
471
472/// Payload for [`AuditEvent::AclGranted`] / [`AuditEvent::AclUpdated`] — the
473/// state of an ACL entry after the change.
474#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
475#[serde(rename_all = "camelCase")]
476pub struct AclChangeData {
477    /// The DID whose authority changed.
478    pub did: String,
479    /// The role granted / now held (e.g. `"admin"`, `"moderator"`, `"member"`).
480    pub role: String,
481    /// Context scoping (empty = super-admin / community-wide).
482    #[serde(default, skip_serializing_if = "Vec::is_empty")]
483    pub contexts: Vec<String>,
484    /// Expiry (RFC3339), if the grant is time-bounded.
485    #[serde(default, skip_serializing_if = "Option::is_none")]
486    pub expires_at: Option<String>,
487}
488
489/// Payload for [`AuditEvent::AclRevoked`].
490#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
491#[serde(rename_all = "camelCase")]
492pub struct AclRevokedData {
493    pub did: String,
494    /// The role the DID held immediately before revocation, if known.
495    #[serde(default, skip_serializing_if = "Option::is_none")]
496    pub prior_role: Option<String>,
497}
498
499/// Payload for [`AuditEvent::InvitationIssued`].
500#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
501#[serde(rename_all = "camelCase")]
502pub struct InvitationIssuedData {
503    /// The VIC's `id` (its consumption / revocation handle).
504    pub invitation_id: String,
505    /// The invited DID (`credentialSubject.id`).
506    pub subject_did: String,
507    /// Role the invite grants on redemption, if any.
508    #[serde(default, skip_serializing_if = "Option::is_none")]
509    pub role: Option<String>,
510    /// Expiry (RFC3339).
511    pub valid_until: String,
512    /// Revocation status-list slot allocated to this invite.
513    #[serde(default, skip_serializing_if = "Option::is_none")]
514    pub status_list_index: Option<u32>,
515}
516
517/// Payload for [`AuditEvent::InvitationRevoked`].
518#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
519#[serde(rename_all = "camelCase")]
520pub struct InvitationRevokedData {
521    pub invitation_id: String,
522    #[serde(default, skip_serializing_if = "Option::is_none")]
523    pub subject_did: Option<String>,
524    /// `true` if this call flipped the bit, `false` if it was already revoked.
525    pub newly_revoked: bool,
526}
527
528/// Payload for [`AuditEvent::SessionRevoked`].
529#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
530#[serde(rename_all = "camelCase")]
531pub struct SessionRevokedData {
532    /// The specific session id revoked, for a single-session revoke.
533    #[serde(default, skip_serializing_if = "Option::is_none")]
534    pub session_id: Option<String>,
535    /// Number of sessions revoked (1 for a single id, N for revoke-by-DID).
536    pub revoked_count: u32,
537}
538
539/// Payload for [`AuditEvent::SignedOut`].
540#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
541#[serde(rename_all = "camelCase")]
542pub struct SignedOutData {
543    /// Session ended. Always known — the caller's own JWT names it.
544    ///
545    /// There is deliberately no "did it exist" flag: the `AuthClaims`
546    /// extractor rejects a token whose session row is gone, so the
547    /// handler only ever runs against a live session. A second
548    /// sign-out with the same token 401s and never reaches here.
549    pub session_id: String,
550}
551
552/// Payload for [`AuditEvent::BackupExported`] / [`AuditEvent::BackupImported`].
553#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
554#[serde(rename_all = "camelCase")]
555pub struct BackupData {
556    /// Number of keyspaces included in the backup.
557    pub keyspace_count: u32,
558    /// The `vtc_did` the backup belongs to (compatibility anchor).
559    #[serde(default, skip_serializing_if = "Option::is_none")]
560    pub vtc_did: Option<String>,
561}
562
563/// Payload for [`AuditEvent::AdminInviteCreated`] / [`AuditEvent::AdminInviteRevoked`].
564#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
565#[serde(rename_all = "camelCase")]
566pub struct AdminInviteData {
567    /// The invite token's `jti`.
568    pub jti: String,
569    /// The admin DID the invite is bound to, if any.
570    #[serde(default, skip_serializing_if = "Option::is_none")]
571    pub admin_did: Option<String>,
572}
573
574/// Payload for [`AuditEvent::SchemaRegistered`] / [`AuditEvent::SchemaDeleted`].
575#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
576#[serde(rename_all = "camelCase")]
577pub struct SchemaChangeData {
578    /// The schema `type_uri` or accepts-criterion id.
579    pub id: String,
580    /// `"schema"` or `"accepts"`.
581    pub kind: String,
582}
583
584#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
585#[serde(rename_all = "camelCase")]
586pub struct CommunityInstalledData {
587    pub community_did: String,
588    /// `jti` of the install token that was consumed. Lets a forensic
589    /// audit correlate the bootstrap with the specific install URL
590    /// the operator clicked.
591    pub install_token_jti: String,
592}
593
594#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
595#[serde(rename_all = "camelCase")]
596pub struct EmergencyBootstrapData {
597    /// Host name of the machine running the CLI command, as
598    /// reported by the OS. Recorded for forensic context — the CLI
599    /// can't be trusted, but a mismatch with the expected operator
600    /// host is a useful smoke signal.
601    pub operator_hostname: String,
602    /// Wall clock at the time the CLI ran. Distinct from the
603    /// envelope timestamp, which is when the daemon next started
604    /// and emitted the event — the gap between the two is itself
605    /// audit-worthy.
606    pub invoked_at: DateTime<Utc>,
607}
608
609#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
610#[serde(rename_all = "camelCase")]
611pub struct AdminPasskeyData {
612    /// Hex-encoded WebAuthn credential id. Operator-recognisable;
613    /// distinct from the cred_id bytes the storage layer holds.
614    pub credential_id_hex: String,
615    /// Operator-friendly label (e.g. `"MacBook Air Touch ID"`).
616    pub label: String,
617    /// `usb` / `nfc` / `ble` / `internal` etc., as WebAuthn reports
618    /// them. Helpful for "which device just got revoked" UX.
619    pub transports: Vec<String>,
620}
621
622#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
623#[serde(rename_all = "camelCase")]
624pub struct ConfigChangedData {
625    pub changes: Vec<ConfigChange>,
626    /// `true` when at least one changed key is restart-required.
627    /// Emitter computes this from the per-key taxonomy (M0.8) so the
628    /// audit consumer doesn't need to know the schema.
629    pub requires_restart: bool,
630}
631
632/// One field's worth of change. `old_value` is `None` if the key
633/// wasn't previously set (default-only); `new_value` is the
634/// post-PATCH value. Use [`Self::redact_if`] before persisting to
635/// mask sensitive values.
636#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
637#[serde(rename_all = "camelCase")]
638pub struct ConfigChange {
639    pub key: String,
640    pub old_value: Option<Value>,
641    pub new_value: Value,
642    pub source_before: ConfigSource,
643}
644
645impl ConfigChange {
646    /// Mask the value fields in-place if `sensitive(&self.key)`.
647    /// Returns `true` if a redaction was applied so the caller can
648    /// log it.
649    pub fn redact_if<F>(&mut self, sensitive: F) -> bool
650    where
651        F: Fn(&str) -> bool,
652    {
653        if sensitive(&self.key) {
654            self.old_value = Some(Value::String(REDACTED_MARKER.to_string()));
655            self.new_value = Value::String(REDACTED_MARKER.to_string());
656            true
657        } else {
658            false
659        }
660    }
661}
662
663/// Where the prior value came from in the three-layer config
664/// overlay. Mirrors the source annotation surfaced on
665/// `GET /v1/admin/config` (spec §14.6). Reproduced here so the
666/// audit log is self-contained and doesn't need the config module's
667/// type to deserialise.
668#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
669#[serde(rename_all = "snake_case")]
670pub enum ConfigSource {
671    Env,
672    Db,
673    Toml,
674    Default,
675}
676
677#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
678#[serde(rename_all = "camelCase")]
679pub struct ConfigReloadedData {
680    /// Keys that actually re-applied. Excludes keys whose new value
681    /// equalled the live value (no-op).
682    pub keys_reloaded: Vec<String>,
683}
684
685#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
686#[serde(rename_all = "camelCase")]
687pub struct RestartRequestedData {
688    /// `restart.drain_timeout` value (seconds) the daemon will use
689    /// when draining in-flight requests. Lets an oncall correlate a
690    /// long-tail timeout with a restart.
691    pub drain_timeout_seconds: u64,
692}
693
694/// One field's before/after change, recorded on enrichable "Updated"
695/// audit events so the log can reconstruct *what* changed, not just
696/// which field. `old` / `new` are JSON values; either is omitted when
697/// the field was newly set or cleared.
698#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
699#[serde(rename_all = "camelCase")]
700pub struct FieldChange {
701    pub field: String,
702    #[serde(default, skip_serializing_if = "Option::is_none")]
703    pub old: Option<Value>,
704    #[serde(default, skip_serializing_if = "Option::is_none")]
705    pub new: Option<Value>,
706}
707
708#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
709#[serde(rename_all = "camelCase")]
710pub struct CommunityProfileUpdatedData {
711    /// Names of fields that changed (e.g. `name`, `description`,
712    /// `logo_url`, `extensions`).
713    pub fields_changed: Vec<String>,
714    /// Before/after values for each changed field. Additive over
715    /// `fields_changed` (kept for back-compat); empty on pre-enrichment
716    /// rows and emitters that don't diff.
717    #[serde(default, skip_serializing_if = "Vec::is_empty")]
718    pub changes: Vec<FieldChange>,
719}
720
721#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
722#[serde(rename_all = "camelCase")]
723pub struct AuditKeyRotatedData {
724    pub previous_key_id: String,
725    pub new_key_id: String,
726    pub rotation_reason: RotationReason,
727}
728
729#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
730#[serde(rename_all = "camelCase")]
731pub struct MemberUpdatedData {
732    /// Names of the fields changed on this PATCH (e.g.
733    /// `["publishConsent", "departurePreference"]`).
734    pub fields_changed: Vec<String>,
735    /// Before/after values for each changed field, so the change is
736    /// reconstructable. Additive over `fields_changed`; empty on
737    /// pre-enrichment rows.
738    #[serde(default, skip_serializing_if = "Vec::is_empty")]
739    pub changes: Vec<FieldChange>,
740}
741
742#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
743#[serde(rename_all = "camelCase")]
744pub struct RoleChangedData {
745    /// Previous role, serialised via the service's role-enum
746    /// `Display` impl (e.g. `"moderator"`, `"custom:editor"`).
747    /// String-typed so this struct stays in vti-common without
748    /// taking a dep on vtc-service's `VtcRole`.
749    pub previous_role: String,
750    pub new_role: String,
751}
752
753#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
754#[serde(rename_all = "camelCase")]
755pub struct AdminPromotedData {
756    /// Role the member held immediately before promotion.
757    pub previous_role: String,
758    /// Credential id of the passkey whose UV authorised the promotion.
759    ///
760    /// Populated when the ceremony and the promotion were the same request
761    /// (the retired fused `promote-to-admin` endpoint). Since the fold, the UV
762    /// happens in a separate step-up ceremony, so this is empty and
763    /// `authorising_session_id` is the link to the
764    /// [`AuthSteppedUp`](AuditEvent::AuthSteppedUp) row carrying the
765    /// credential. Retained rather than removed so archived envelopes still
766    /// deserialise — `#[serde(default)]` on both fields means rows written on
767    /// either side of the fold read back cleanly.
768    #[serde(default)]
769    pub authorising_credential_id: String,
770    /// Session whose live step-up elevation authorised this promotion. Join
771    /// key to the `AuthSteppedUp` row for the credential and the moment of
772    /// user verification.
773    #[serde(default)]
774    pub authorising_session_id: String,
775}
776
777/// Payload for [`AuditEvent::AuthSteppedUp`].
778#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
779#[serde(rename_all = "camelCase")]
780pub struct AuthSteppedUpData {
781    /// The elevated session. Privileged operations performed inside the
782    /// window record this id, so a reviewer can walk from an operation back to
783    /// the gesture that authorised it.
784    pub session_id: String,
785    /// Credential id (hex) of the passkey that asserted user verification.
786    pub credential_id: String,
787    /// Assurance level the session now holds.
788    pub acr: String,
789    /// When the elevation lapses. After this, the same session must re-run the
790    /// ceremony before it can authorise anything else.
791    pub expires_at: DateTime<Utc>,
792}
793
794#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
795#[serde(rename_all = "camelCase")]
796pub struct JoinRequestData {
797    /// UUID of the JoinRequest row in the `join_requests:` keyspace.
798    pub request_id: String,
799    /// Transport the request arrived over (`"rest"` / `"didcomm"`),
800    /// recorded for diagnostics.
801    pub transport: String,
802}
803
804#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
805#[serde(rename_all = "camelCase")]
806pub struct JoinRequestRejectedData {
807    pub request_id: String,
808    /// Operator-supplied reason, capped at 1024 chars at the
809    /// route layer. May be empty.
810    pub reason: String,
811    /// Structured policy verdict that drove an automatic rejection
812    /// (the serialized `Verdict::Deny`, incl. its `code`). `None` for
813    /// a manual admin reject, where `reason` is the operator's words.
814    #[serde(default, skip_serializing_if = "Option::is_none")]
815    pub policy_decision: Option<Value>,
816}
817
818#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
819#[serde(rename_all = "camelCase")]
820pub struct MemberAddedData {
821    /// Role assigned at admission. Phase 1 always emits
822    /// `"member"` (the default role on approve); future phases
823    /// may emit `"moderator"` / `"issuer"` etc. when invite
824    /// flows admit at higher tiers.
825    pub role: String,
826    /// `request_id` of the JoinRequest the admission resolved.
827    /// `None` for out-of-band additions (e.g. emergency bootstrap)
828    /// that don't pass through a join request.
829    #[serde(default, skip_serializing_if = "Option::is_none")]
830    pub via_join_request_id: Option<String>,
831}
832
833#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
834#[serde(rename_all = "camelCase")]
835pub struct MemberRemovedData {
836    /// `disposition` after resolving `PolicyDefault`. One of
837    /// `"purge"`, `"tombstone"`, `"historical"`.
838    pub disposition: String,
839    /// Optional operator-supplied reason on admin removal. Empty
840    /// for self-removal.
841    #[serde(default, skip_serializing_if = "String::is_empty")]
842    pub reason: String,
843    /// Role the member held immediately before removal (e.g.
844    /// `"admin"`, `"member"`). `None` when removing a tombstone that
845    /// no longer has an ACL row. The credential-revocation cascade is
846    /// audited separately via the paired `StatusListFlipped` event.
847    #[serde(default, skip_serializing_if = "Option::is_none")]
848    pub prior_role: Option<String>,
849}
850
851/// Payload for [`AuditEvent::PolicyUploaded`].
852///
853/// Records the *immutable* outcome of compilation: the new id, what
854/// purpose this revision targets, the source hash, and the
855/// monotone per-purpose version. The actual Rego source stays in
856/// the `policies:<id>` row — the audit envelope only carries the
857/// hash so the log doesn't bloat for large policies.
858#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
859#[serde(rename_all = "camelCase")]
860pub struct PolicyUploadedData {
861    /// UUID of the new Policy row.
862    pub policy_id: String,
863    /// Wire-form camelCase purpose (`"join"`, `"removal"`, …).
864    pub purpose: String,
865    /// SHA-256 of the source, lowercase hex.
866    pub sha256: String,
867    /// Per-purpose monotone counter the upload landed under.
868    pub version: u32,
869}
870
871/// Payload for [`AuditEvent::MembershipReciprocated`].
872///
873/// The audit envelope's `target_did` already carries the member; this
874/// adds the join request that admitted them, the VMC they reciprocated,
875/// and the id of the member-issued reciprocal VC, so an investigator can
876/// chain "admitted → reciprocated" without scanning the members keyspace.
877#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
878#[serde(rename_all = "camelCase")]
879pub struct MembershipReciprocatedData {
880    /// UUID of the JoinRequest the reciprocation closes.
881    pub request_id: String,
882    /// `id` of the VMC the member reciprocated (the community → member
883    /// half of the edge).
884    pub vmc_id: String,
885    /// `id` of the member-issued reciprocal VC (the member → community
886    /// half).
887    pub reciprocal_vc_id: String,
888}
889
890/// Payload for [`AuditEvent::VmcIssued`] + [`AuditEvent::VecIssued`].
891///
892/// The audit envelope's `target_did` already carries the member;
893/// this struct adds the credential id + type + the issuance
894/// window so an investigator can correlate "who got which VC
895/// when" without cross-referencing the credential store.
896#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
897#[serde(rename_all = "camelCase")]
898pub struct CredentialIssuedData {
899    /// VC `id` URI (typically `urn:uuid:<server-allocated>`).
900    pub credential_id: String,
901    /// Wire-form credential type (`"VerifiableMembershipCredential"`
902    /// for VMC, `"VerifiableEndorsementCredential"` for VEC).
903    pub credential_type: String,
904    /// RFC3339 `validFrom` from the issued VC.
905    pub valid_from: String,
906    /// RFC3339 `validUntil` from the issued VC.
907    pub valid_until: String,
908    /// Status-list slot for VMCs (revocation list). `None` for
909    /// VECs and other credential types that don't carry a
910    /// status-list entry.
911    #[serde(default, skip_serializing_if = "Option::is_none")]
912    pub status_list_index: Option<u32>,
913}
914
915/// Payload for [`AuditEvent::MembershipRenewed`]. Phase 2 M2.13.
916#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
917#[serde(rename_all = "camelCase")]
918pub struct MembershipRenewedData {
919    /// New VMC id.
920    pub vmc_id: String,
921    /// New role VEC id.
922    pub role_vec_id: String,
923    /// Whether the `personhood.rego` re-eval produced a different
924    /// flag than the prior VMC (spec §6.3 step 3). Phase 2 ships
925    /// the deny-all stub so this is always `false` in MVP; the
926    /// field is on the wire from day one so Phase 4's
927    /// `assert`/`revoke` endpoints don't break the audit schema.
928    pub personhood_changed: bool,
929}
930
931/// Payload for [`AuditEvent::StatusListFlipped`]. Phase 2 M2.14.
932#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
933#[serde(rename_all = "camelCase")]
934pub struct StatusListFlippedData {
935    /// Wire-form status purpose (`"revocation"` / `"suspension"`).
936    pub purpose: String,
937    /// Slot index that was flipped.
938    pub index: u32,
939    /// Direction of the flip — `true` = revoked/suspended,
940    /// `false` = un-suspended. Revocation flips are one-way per
941    /// spec §6.2; suspension flips can go either direction.
942    pub revoked: bool,
943}
944
945/// Payload for [`AuditEvent::RegistryRecordPolicyOverride`].
946/// Phase 3 M3.6. Carries `reason` (always `"rtbf"` in Phase 3
947/// — future overrides could land additional reason codes),
948/// the attempted disposition the policy would have enforced,
949/// and the effective disposition the override produced.
950#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
951#[serde(rename_all = "camelCase")]
952pub struct RegistryRecordPolicyOverrideData {
953    /// Short reason code. Phase 3 only emits `"rtbf"`; the
954    /// field is shaped as a free string so Phase 4+ can
955    /// introduce additional codes (`"legal-hold"`, etc.)
956    /// without bumping the envelope version.
957    pub reason: String,
958    /// The disposition the active `registry.rego.min_disposition`
959    /// would have enforced (e.g. `"tombstone"`).
960    pub attempted_disposition: String,
961    /// The disposition the override applied (always `"purge"`
962    /// for RTBF overrides; Phase 4+ may add new effective
963    /// dispositions for legal-hold paths).
964    pub effective_disposition: String,
965}
966
967/// Payload for [`AuditEvent::RegistrySyncSucceeded`] +
968/// [`AuditEvent::RegistrySyncFailed`]. Phase 3 M3.4. The
969/// `actor_did` on the envelope is the VTC's own DID; the
970/// `target_did` is the member being synced.
971#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
972#[serde(rename_all = "camelCase")]
973pub struct RegistrySyncOutcomeData {
974    /// UUID of the `SyncJob` row.
975    pub job_id: String,
976    /// Wire-form `SyncJobKind` — `"publishMember"`,
977    /// `"updateMember"`, `"deleteMember"`, or
978    /// `"markDeparted"`.
979    pub kind: String,
980    /// Number of attempts the job made (1 for happy-path
981    /// succeed-on-first-try, higher when retries fired).
982    pub attempts: u32,
983    /// On failure: the last error message. On success:
984    /// `None`.
985    #[serde(default, skip_serializing_if = "Option::is_none")]
986    pub last_error: Option<String>,
987}
988
989/// Payload for [`AuditEvent::CrossCommunitySessionMinted`].
990/// Phase 3 M3.10. The envelope's `actor_did` (HMAC-hashed per
991/// §11.1) is the bearer of the foreign credentials — i.e. the
992/// caller of `POST /v1/auth/recognise`. The envelope's
993/// `target_did` is the local subject DID the session was minted
994/// to (same value on `minted`; absent on `denied` because no
995/// local subject was established).
996#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
997#[serde(rename_all = "camelCase")]
998pub struct CrossCommunitySessionMintedData {
999    /// `"minted"` or `"denied"`. Stable wire form so SIEM
1000    /// rules can key on it.
1001    pub outcome: String,
1002    /// Foreign community's issuer DID (e.g.
1003    /// `did:webvh:peer.example.com:abc`).
1004    pub foreign_issuer_did: String,
1005    /// Role claim from the foreign VEC — present even on
1006    /// `denied` so operators can see what was attempted.
1007    #[serde(default, skip_serializing_if = "Option::is_none")]
1008    pub foreign_role: Option<String>,
1009    /// Local role the foreign role mapped onto. Populated on
1010    /// `minted` only.
1011    #[serde(default, skip_serializing_if = "Option::is_none")]
1012    pub mapped_role: Option<String>,
1013    /// Clamped session TTL in seconds. `min(jwt_default,
1014    /// vec.validUntil - now, vmc.validUntil - now)`. Populated
1015    /// on `minted` only.
1016    #[serde(default, skip_serializing_if = "Option::is_none")]
1017    pub ttl_seconds: Option<u64>,
1018    /// On `denied`: a short reason code from
1019    /// [`RecognitionError::reason_code`] or
1020    /// `"role-mapping-denied"` for the policy-rejection arm.
1021    #[serde(default, skip_serializing_if = "Option::is_none")]
1022    pub reason: Option<String>,
1023}
1024
1025// ─── Phase 4 payload structs ──────────────────────────────
1026
1027/// Payload for [`AuditEvent::VrcPublished`]. Phase 4 M4.6.
1028/// Self-issued trust edge from one member to another.
1029#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1030#[serde(rename_all = "camelCase")]
1031pub struct VrcPublishedData {
1032    /// Server-allocated id (UUID) of the relationship row.
1033    /// The wire-form `id` lives at `urn:uuid:<vrc_id>` on the
1034    /// VRC's top-level `id` field.
1035    pub vrc_id: String,
1036    /// Subject DID — the *other* member the edge points at.
1037    /// HMAC-hashed in `target_did_hash` on the envelope; the
1038    /// raw value lives here only when operator policy allows
1039    /// (default: omitted).
1040    #[serde(default, skip_serializing_if = "Option::is_none")]
1041    pub subject_did: Option<String>,
1042    /// Free-form trust-relationship type tag from the VRC's
1043    /// `endorsement.type`. Examples: `"endorses"`, `"reports-to"`,
1044    /// `"collaborates-with"` — operator-defined.
1045    pub edge_type: String,
1046}
1047
1048/// Payload for [`AuditEvent::VrcRevoked`]. Phase 4 M4.6.
1049/// Issued whether the original issuer or an admin performed
1050/// the revocation; the envelope's `actor_did` distinguishes.
1051#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1052#[serde(rename_all = "camelCase")]
1053pub struct VrcRevokedData {
1054    pub vrc_id: String,
1055    /// `"issuer"` (the original member revoked their own VRC)
1056    /// or `"admin"` (an admin revoked on behalf of the
1057    /// community).
1058    pub revoked_by: String,
1059}
1060
1061/// Payload for [`AuditEvent::PersonhoodAsserted`]. Phase 4
1062/// M4.3. The envelope's `actor_did` is the asserter (admin or
1063/// issuer); `target_did_hash` is the member.
1064#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1065#[serde(rename_all = "camelCase")]
1066pub struct PersonhoodAssertedData {
1067    /// VMC id minted with the new `personhood: true` flag.
1068    /// Empty when the assert ran inside an idempotent retry
1069    /// that didn't mint a fresh credential.
1070    pub vmc_id: String,
1071    /// `now` at assert time, in RFC3339. Persisted on the
1072    /// Member row as `personhood_asserted_at` (per D2 review,
1073    /// only the timestamp persists — not the VP itself).
1074    pub asserted_at: String,
1075}
1076
1077/// Payload for [`AuditEvent::PersonhoodRevoked`]. Phase 4
1078/// M4.4 / M4.2.2.
1079#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1080#[serde(rename_all = "camelCase")]
1081pub struct PersonhoodRevokedData {
1082    /// VMC id minted with the new `personhood: false` flag.
1083    /// `None` on the `refuse` arm of `on_personhood_fail`
1084    /// (no VMC re-mint).
1085    #[serde(default, skip_serializing_if = "Option::is_none")]
1086    pub vmc_id: Option<String>,
1087    /// Discriminator for the three triggers: `"admin"`,
1088    /// `"self"`, or `"renewal-policy"`.
1089    pub reason: String,
1090}
1091
1092/// Payload for [`AuditEvent::CustomEndorsementIssued`]. Phase 4
1093/// M4.8.2. The envelope's `actor_did` is the issuer (issuer-
1094/// role member or admin); `target_did_hash` is the subject.
1095#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1096#[serde(rename_all = "camelCase")]
1097pub struct CustomEndorsementIssuedData {
1098    /// Server-allocated id (UUID) of the endorsement row.
1099    pub endorsement_id: String,
1100    /// The registered endorsement type URI.
1101    pub endorsement_type: String,
1102    /// Allocated slot index on the shared `Revocation` status
1103    /// list (D8 review). Paired with the credential's
1104    /// `credentialStatus.statusListIndex` field.
1105    pub status_list_index: u32,
1106}
1107
1108/// Payload for [`AuditEvent::CustomEndorsementRevoked`]. Phase 4
1109/// M4.8.4. Paired with the existing `StatusListFlipped` envelope
1110/// so the bit-flip is independently auditable.
1111#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1112#[serde(rename_all = "camelCase")]
1113pub struct CustomEndorsementRevokedData {
1114    pub endorsement_id: String,
1115    pub endorsement_type: String,
1116}
1117
1118/// Payload for [`AuditEvent::EndorsementTypeRegistered`].
1119/// Phase 4 M4.8.1 (D4 review).
1120#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1121#[serde(rename_all = "camelCase")]
1122pub struct EndorsementTypeRegisteredData {
1123    /// The newly-registered type URI. Operators use this on
1124    /// the issuance path's `body.type` field.
1125    pub type_uri: String,
1126    /// Optional human-readable description supplied at
1127    /// registration time.
1128    #[serde(default, skip_serializing_if = "Option::is_none")]
1129    pub description: Option<String>,
1130}
1131
1132/// Payload for [`AuditEvent::EndorsementTypeDeleted`]. Phase 4
1133/// M4.8.1.
1134#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1135#[serde(rename_all = "camelCase")]
1136pub struct EndorsementTypeDeletedData {
1137    pub type_uri: String,
1138    /// Count of live endorsements of this type at deletion. Deletion
1139    /// is refused while any exist, so this is `0` on success — recorded
1140    /// so the audit trail documents that the no-orphans precondition
1141    /// held. `default` keeps pre-enrichment rows parseable.
1142    #[serde(default)]
1143    pub live_endorsements_at_delete: u32,
1144}
1145
1146/// Payload for [`AuditEvent::WebsiteFileWritten`]. Phase 5 M5.5.2.
1147/// Records enough material to audit the deploy without persisting
1148/// the file body itself — the SHA-256 + size pin what was written.
1149#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1150#[serde(rename_all = "camelCase")]
1151pub struct WebsiteFileWrittenData {
1152    /// Path **relative to** `website.root_dir`, NFC-normalised
1153    /// and free of `..` segments.
1154    pub path: String,
1155    /// File size in bytes (post-write).
1156    pub size_bytes: u64,
1157    /// SHA-256 of the written content, hex-encoded. Doubles as
1158    /// the ETag value the response returns to the caller.
1159    pub sha256: String,
1160}
1161
1162/// Payload for [`AuditEvent::WebsiteFileDeleted`]. Phase 5 M5.5.2.
1163#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1164#[serde(rename_all = "camelCase")]
1165pub struct WebsiteFileDeletedData {
1166    /// Path **relative to** `website.root_dir`, NFC-normalised.
1167    pub path: String,
1168}
1169
1170/// Payload for [`AuditEvent::WebsiteBundleDeployed`]. Phase 5
1171/// M5.5.3. Live + managed modes share this variant; the
1172/// `target_generation` + `pruned_generations` fields are populated
1173/// in managed mode and zero in live mode.
1174#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1175#[serde(rename_all = "camelCase")]
1176pub struct WebsiteBundleDeployedData {
1177    /// SHA-256 of the uploaded tar.gz, hex-encoded.
1178    pub bundle_sha256: String,
1179    /// Size of the uploaded tar.gz, in bytes.
1180    pub bundle_size_bytes: u64,
1181    /// `"live"` or `"managed"` (matches
1182    /// `website.deploy_mode`).
1183    pub deploy_mode: String,
1184    /// New generation number in managed mode; `0` in live mode.
1185    pub target_generation: u32,
1186    /// Number of old generations pruned to honour
1187    /// `managed_generations_keep` (managed mode only; `0` in
1188    /// live mode).
1189    pub pruned_generations: u32,
1190}
1191
1192/// Payload for [`AuditEvent::WebsiteGenerationRolledBack`]. Phase
1193/// 5 M5.5.4. Managed mode only — the symlink swap is the audit
1194/// surface.
1195#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1196#[serde(rename_all = "camelCase")]
1197pub struct WebsiteGenerationRolledBackData {
1198    /// Generation `current` pointed at before the rollback.
1199    pub from_generation: u32,
1200    /// Generation `current` now points at.
1201    pub to_generation: u32,
1202}
1203
1204/// Payload for [`AuditEvent::AdminUiServed`]. Phase 5 M5.7.2.
1205/// Captures enough material that an operator who suspects an
1206/// admin UX compromise can pin the exact bytes that were baked
1207/// into the running daemon.
1208#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1209#[serde(rename_all = "camelCase")]
1210pub struct AdminUiServedData {
1211    /// SHA-256 of the baked `index.html` (hex-encoded). Doubles
1212    /// as the `version` field of `GET /admin/build-info.json`.
1213    pub index_sha256: String,
1214    /// Number of files baked into the binary (informational —
1215    /// surfaces accidental directory bloat).
1216    pub file_count: u32,
1217    /// `"embedded"` or `"external"`. Embedded serves the baked
1218    /// SPA; external delegates to an operator-supplied origin.
1219    pub mode: String,
1220    /// Daemon build version (`CARGO_PKG_VERSION`) that served this
1221    /// SPA, so the running admin-UI build correlates with a specific
1222    /// release. `None` on pre-enrichment rows.
1223    #[serde(default, skip_serializing_if = "Option::is_none")]
1224    pub daemon_version: Option<String>,
1225}
1226
1227/// Payload for [`AuditEvent::RegistryStatusChanged`]. Phase 3
1228/// M3.2.
1229#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1230#[serde(rename_all = "camelCase")]
1231pub struct RegistryStatusChangedData {
1232    /// Status before the flip — `"active"` or `"degraded"`.
1233    pub from: String,
1234    /// Status after the flip.
1235    pub to: String,
1236    /// Optional reason — error string from the last health
1237    /// probe when flipping to `degraded`, or a short
1238    /// confirmation message when flipping back to `active`.
1239    #[serde(default, skip_serializing_if = "Option::is_none")]
1240    pub reason: Option<String>,
1241}
1242
1243/// Payload for [`AuditEvent::DidRotated`]. Phase 2 M2.15.
1244#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1245#[serde(rename_all = "camelCase")]
1246pub struct DidRotatedData {
1247    pub old_did: String,
1248    pub new_did: String,
1249    /// DID method of the new DID — `"did:key"` /
1250    /// `"did:webvh"`. Spec §10.5 keeps the two paths
1251    /// cryptographically distinct so SIEM rules can target
1252    /// each.
1253    pub method: String,
1254    /// New VMC id minted in the same transaction (status-list
1255    /// slot reused). `None` if issuance was skipped (e.g.
1256    /// daemon misconfiguration).
1257    #[serde(default, skip_serializing_if = "Option::is_none")]
1258    pub vmc_id: Option<String>,
1259    /// New role VEC id minted in the same transaction.
1260    #[serde(default, skip_serializing_if = "Option::is_none")]
1261    pub role_vec_id: Option<String>,
1262    /// Role the rotating member held before the rotation, carried
1263    /// across unchanged. Lets a reviewer see the privilege level a
1264    /// rotated key inherits. `None` on pre-enrichment rows.
1265    #[serde(default, skip_serializing_if = "Option::is_none")]
1266    pub prior_role: Option<String>,
1267    /// Why the member rotated, as declared when they requested the
1268    /// rotation challenge. `None` on pre-enrichment rows and whenever
1269    /// the member declined to say.
1270    ///
1271    /// **Self-asserted, not proven.** The rotation signatures cover
1272    /// `{rotationId, oldDid, newDid, expiresAt}` and nothing else, so
1273    /// this value is the member's own claim about their motive. It is
1274    /// bound to the authenticated session that opened the ceremony and
1275    /// cannot be altered by whoever submits the finish request, but a
1276    /// member who lies about *why* they rotated will be recorded
1277    /// faithfully lying. Treat it as intent, never as evidence.
1278    #[serde(default, skip_serializing_if = "Option::is_none")]
1279    pub rotation_reason: Option<DidRotationReason>,
1280}
1281
1282/// Why a member rotated their DID, as declared at challenge time.
1283///
1284/// Separate from [`crate::audit::RotationReason`], which describes
1285/// *audit-HMAC-key* rotation: that enum serializes PascalCase, is
1286/// already persisted in `audit_key:` rows (so it cannot be re-cased),
1287/// and its variants (`Initial`, `Routine` meaning "the background task
1288/// fired") carry no sensible reading in this domain.
1289#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1290#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1291#[serde(rename_all = "camelCase")]
1292pub enum DidRotationReason {
1293    /// Routine hygiene — scheduled or self-directed key refresh, no
1294    /// suspected compromise.
1295    Routine,
1296    /// The old key is believed exposed. The security-relevant value:
1297    /// a SIEM rule keying on this should escalate, and everything the
1298    /// old DID did before the rotation is suspect.
1299    Compromise,
1300    /// The device holding the old key was lost, destroyed, or
1301    /// replaced. Distinct from `Compromise` — no exposure claimed.
1302    DeviceLoss,
1303    /// Moving between DID methods or hosts (e.g. `did:key` →
1304    /// `did:webvh`), the identity being otherwise unchanged.
1305    Migration,
1306    /// The member gave no reason.
1307    Unspecified,
1308}
1309
1310/// Payload for [`AuditEvent::PolicyActivated`].
1311///
1312/// Records the active-pointer flip for a purpose. `previous_policy_id`
1313/// is `None` when the purpose had no prior active row (first
1314/// activation for that purpose) — that case is itself audit-worthy
1315/// and distinct from "activated a successor". Carries the new
1316/// revision's hash so consumers don't have to cross-reference the
1317/// `PolicyUploaded` event to know what bytecode is now live.
1318#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1319#[serde(rename_all = "camelCase")]
1320pub struct PolicyActivatedData {
1321    pub policy_id: String,
1322    pub purpose: String,
1323    pub sha256: String,
1324    #[serde(default, skip_serializing_if = "Option::is_none")]
1325    pub previous_policy_id: Option<String>,
1326}
1327
1328// ---------------------------------------------------------------------------
1329// Tests
1330// ---------------------------------------------------------------------------
1331
1332#[cfg(test)]
1333mod tests {
1334    use super::*;
1335    use serde_json::json;
1336
1337    fn round_trip(event: &AuditEvent) {
1338        let s = serde_json::to_string(event).unwrap();
1339        let back: AuditEvent = serde_json::from_str(&s).unwrap();
1340        assert_eq!(&back, event);
1341    }
1342
1343    fn wire_value(event: &AuditEvent) -> Value {
1344        serde_json::to_value(event).unwrap()
1345    }
1346
1347    // ──────────── tag + content shape ────────────
1348
1349    #[test]
1350    fn community_installed_tagged_wire_shape() {
1351        let e = AuditEvent::CommunityInstalled(CommunityInstalledData {
1352            community_did: "did:webvh:example.com:abc".into(),
1353            install_token_jti: "jti-1".into(),
1354        });
1355        let v = wire_value(&e);
1356        assert_eq!(v["type"], "CommunityInstalled");
1357        assert_eq!(v["data"]["communityDid"], "did:webvh:example.com:abc");
1358        assert_eq!(v["data"]["installTokenJti"], "jti-1");
1359        round_trip(&e);
1360    }
1361
1362    #[test]
1363    fn emergency_bootstrap_tagged_wire_shape() {
1364        let invoked_at = DateTime::parse_from_rfc3339("2026-05-12T00:00:00Z")
1365            .unwrap()
1366            .with_timezone(&Utc);
1367        let e = AuditEvent::EmergencyBootstrapInvoked(EmergencyBootstrapData {
1368            operator_hostname: "ops-01.example.com".into(),
1369            invoked_at,
1370        });
1371        let v = wire_value(&e);
1372        assert_eq!(v["type"], "EmergencyBootstrapInvoked");
1373        assert_eq!(v["data"]["operatorHostname"], "ops-01.example.com");
1374        round_trip(&e);
1375    }
1376
1377    #[test]
1378    fn admin_passkey_registered_round_trip() {
1379        let e = AuditEvent::AdminPasskeyRegistered(AdminPasskeyData {
1380            credential_id_hex: "deadbeef".into(),
1381            label: "MacBook Air Touch ID".into(),
1382            transports: vec!["internal".into(), "hybrid".into()],
1383        });
1384        let v = wire_value(&e);
1385        assert_eq!(v["type"], "AdminPasskeyRegistered");
1386        assert_eq!(v["data"]["credentialIdHex"], "deadbeef");
1387        assert_eq!(v["data"]["transports"][0], "internal");
1388        round_trip(&e);
1389    }
1390
1391    #[test]
1392    fn admin_passkey_revoked_round_trip() {
1393        let e = AuditEvent::AdminPasskeyRevoked(AdminPasskeyData {
1394            credential_id_hex: "feedface".into(),
1395            label: "iPhone Face ID".into(),
1396            transports: vec!["hybrid".into()],
1397        });
1398        let v = wire_value(&e);
1399        assert_eq!(v["type"], "AdminPasskeyRevoked");
1400        round_trip(&e);
1401    }
1402
1403    #[test]
1404    fn config_changed_round_trip() {
1405        let e = AuditEvent::ConfigChanged(ConfigChangedData {
1406            changes: vec![ConfigChange {
1407                key: "log.level".into(),
1408                old_value: Some(json!("info")),
1409                new_value: json!("debug"),
1410                source_before: ConfigSource::Toml,
1411            }],
1412            requires_restart: false,
1413        });
1414        let v = wire_value(&e);
1415        assert_eq!(v["type"], "ConfigChanged");
1416        assert_eq!(v["data"]["changes"][0]["key"], "log.level");
1417        assert_eq!(v["data"]["changes"][0]["newValue"], "debug");
1418        assert_eq!(v["data"]["changes"][0]["sourceBefore"], "toml");
1419        round_trip(&e);
1420    }
1421
1422    #[test]
1423    fn config_reloaded_round_trip() {
1424        let e = AuditEvent::ConfigReloaded(ConfigReloadedData {
1425            keys_reloaded: vec!["log.level".into(), "audit.retention.config_changed".into()],
1426        });
1427        let v = wire_value(&e);
1428        assert_eq!(v["type"], "ConfigReloaded");
1429        assert_eq!(
1430            v["data"]["keysReloaded"][1],
1431            "audit.retention.config_changed"
1432        );
1433        round_trip(&e);
1434    }
1435
1436    #[test]
1437    fn restart_requested_round_trip() {
1438        let e = AuditEvent::RestartRequested(RestartRequestedData {
1439            drain_timeout_seconds: 30,
1440        });
1441        let v = wire_value(&e);
1442        assert_eq!(v["type"], "RestartRequested");
1443        assert_eq!(v["data"]["drainTimeoutSeconds"], 30);
1444        round_trip(&e);
1445    }
1446
1447    #[test]
1448    fn community_profile_updated_round_trip() {
1449        let e = AuditEvent::CommunityProfileUpdated(CommunityProfileUpdatedData {
1450            fields_changed: vec!["name".into(), "logo_url".into()],
1451            changes: vec![FieldChange {
1452                field: "name".into(),
1453                old: Some(json!("Old Name")),
1454                new: Some(json!("New Name")),
1455            }],
1456        });
1457        let v = wire_value(&e);
1458        assert_eq!(v["type"], "CommunityProfileUpdated");
1459        assert_eq!(v["data"]["fieldsChanged"][0], "name");
1460        assert_eq!(v["data"]["changes"][0]["field"], "name");
1461        assert_eq!(v["data"]["changes"][0]["new"], "New Name");
1462        round_trip(&e);
1463    }
1464
1465    #[test]
1466    fn audit_key_rotated_round_trip() {
1467        let e = AuditEvent::AuditKeyRotated(AuditKeyRotatedData {
1468            previous_key_id: "11111111-1111-1111-1111-111111111111".into(),
1469            new_key_id: "22222222-2222-2222-2222-222222222222".into(),
1470            rotation_reason: RotationReason::Rtbf,
1471        });
1472        let v = wire_value(&e);
1473        assert_eq!(v["type"], "AuditKeyRotated");
1474        assert_eq!(v["data"]["rotationReason"], "Rtbf");
1475        round_trip(&e);
1476    }
1477
1478    // ──────────── ConfigChange::redact_if ────────────
1479
1480    #[test]
1481    fn redact_if_masks_sensitive_keys() {
1482        let mut change = ConfigChange {
1483            key: "server.tls.cert_path".into(),
1484            old_value: Some(json!("/etc/old.pem")),
1485            new_value: json!("/etc/new.pem"),
1486            source_before: ConfigSource::Db,
1487        };
1488        let redacted = change.redact_if(|k| k.starts_with("server.tls."));
1489        assert!(redacted);
1490        assert_eq!(change.old_value, Some(json!(REDACTED_MARKER)));
1491        assert_eq!(change.new_value, json!(REDACTED_MARKER));
1492        // Key + source survive — redaction is value-only.
1493        assert_eq!(change.key, "server.tls.cert_path");
1494        assert_eq!(change.source_before, ConfigSource::Db);
1495    }
1496
1497    #[test]
1498    fn redact_if_leaves_non_sensitive_keys_untouched() {
1499        let mut change = ConfigChange {
1500            key: "log.level".into(),
1501            old_value: Some(json!("info")),
1502            new_value: json!("debug"),
1503            source_before: ConfigSource::Toml,
1504        };
1505        let original = change.clone();
1506        let redacted = change.redact_if(|k| k.starts_with("server.tls."));
1507        assert!(!redacted);
1508        assert_eq!(change, original);
1509    }
1510
1511    #[test]
1512    fn redact_if_handles_unset_old_value() {
1513        let mut change = ConfigChange {
1514            key: "server.tls.key_path".into(),
1515            old_value: None,
1516            new_value: json!("/etc/new.key"),
1517            source_before: ConfigSource::Default,
1518        };
1519        change.redact_if(|k| k.starts_with("server.tls."));
1520        // Even when the previous value was unset, redaction inserts a
1521        // <redacted> marker so the audit record can't be distinguished
1522        // from "previously empty, now empty" — preserves the
1523        // sensitivity boundary.
1524        assert_eq!(change.old_value, Some(json!(REDACTED_MARKER)));
1525    }
1526
1527    // ──────────── Variant catalog snapshot ────────────
1528    //
1529    // Pins the wire-discriminator strings. Renaming a variant
1530    // breaks SIEM ingestion and webhook consumers; this test makes
1531    // such a change visible in review.
1532
1533    #[test]
1534    fn policy_uploaded_round_trip() {
1535        let e = AuditEvent::PolicyUploaded(PolicyUploadedData {
1536            policy_id: "11111111-1111-1111-1111-111111111111".into(),
1537            purpose: "join".into(),
1538            sha256: "abc123".into(),
1539            version: 4,
1540        });
1541        let v = wire_value(&e);
1542        assert_eq!(v["type"], "PolicyUploaded");
1543        assert_eq!(
1544            v["data"]["policyId"],
1545            "11111111-1111-1111-1111-111111111111"
1546        );
1547        assert_eq!(v["data"]["purpose"], "join");
1548        assert_eq!(v["data"]["sha256"], "abc123");
1549        assert_eq!(v["data"]["version"], 4);
1550        round_trip(&e);
1551    }
1552
1553    #[test]
1554    fn policy_activated_round_trip_with_predecessor() {
1555        let e = AuditEvent::PolicyActivated(PolicyActivatedData {
1556            policy_id: "22222222-2222-2222-2222-222222222222".into(),
1557            purpose: "removal".into(),
1558            sha256: "deadbeef".into(),
1559            previous_policy_id: Some("11111111-1111-1111-1111-111111111111".into()),
1560        });
1561        let v = wire_value(&e);
1562        assert_eq!(v["type"], "PolicyActivated");
1563        assert_eq!(
1564            v["data"]["previousPolicyId"],
1565            "11111111-1111-1111-1111-111111111111"
1566        );
1567        round_trip(&e);
1568    }
1569
1570    #[test]
1571    fn policy_activated_omits_predecessor_when_none() {
1572        // First activation for a purpose has no predecessor — verify
1573        // the field is omitted on the wire (Option skip), not
1574        // serialised as `null`. SIEM filters key on field presence.
1575        let e = AuditEvent::PolicyActivated(PolicyActivatedData {
1576            policy_id: "22222222-2222-2222-2222-222222222222".into(),
1577            purpose: "personhood".into(),
1578            sha256: "cafe".into(),
1579            previous_policy_id: None,
1580        });
1581        let v = wire_value(&e);
1582        assert!(
1583            v["data"].get("previousPolicyId").is_none(),
1584            "previousPolicyId should be omitted, got {v}"
1585        );
1586        round_trip(&e);
1587    }
1588
1589    #[test]
1590    fn vmc_issued_round_trip() {
1591        let e = AuditEvent::VmcIssued(CredentialIssuedData {
1592            credential_id: "urn:uuid:11111111-1111-1111-1111-111111111111".into(),
1593            credential_type: "VerifiableMembershipCredential".into(),
1594            valid_from: "2026-05-12T00:00:00Z".into(),
1595            valid_until: "2026-06-11T00:00:00Z".into(),
1596            status_list_index: Some(42),
1597        });
1598        let v = wire_value(&e);
1599        assert_eq!(v["type"], "VmcIssued");
1600        assert_eq!(
1601            v["data"]["credentialType"],
1602            "VerifiableMembershipCredential"
1603        );
1604        assert_eq!(v["data"]["statusListIndex"], 42);
1605        round_trip(&e);
1606    }
1607
1608    #[test]
1609    fn vec_issued_round_trip_omits_status_list_index_when_none() {
1610        let e = AuditEvent::VecIssued(CredentialIssuedData {
1611            credential_id: "urn:uuid:22222222-2222-2222-2222-222222222222".into(),
1612            credential_type: "VerifiableEndorsementCredential".into(),
1613            valid_from: "2026-05-12T00:00:00Z".into(),
1614            valid_until: "2026-06-11T00:00:00Z".into(),
1615            status_list_index: None,
1616        });
1617        let v = wire_value(&e);
1618        assert_eq!(v["type"], "VecIssued");
1619        assert!(
1620            v["data"].get("statusListIndex").is_none(),
1621            "statusListIndex should be omitted when None, got {v}"
1622        );
1623        round_trip(&e);
1624    }
1625
1626    #[test]
1627    fn membership_renewed_round_trip() {
1628        let e = AuditEvent::MembershipRenewed(MembershipRenewedData {
1629            vmc_id: "urn:uuid:vmc-1".into(),
1630            role_vec_id: "urn:uuid:vec-1".into(),
1631            personhood_changed: true,
1632        });
1633        let v = wire_value(&e);
1634        assert_eq!(v["type"], "MembershipRenewed");
1635        assert_eq!(v["data"]["personhoodChanged"], true);
1636        round_trip(&e);
1637    }
1638
1639    #[test]
1640    fn status_list_flipped_round_trip() {
1641        let e = AuditEvent::StatusListFlipped(StatusListFlippedData {
1642            purpose: "revocation".into(),
1643            index: 7,
1644            revoked: true,
1645        });
1646        let v = wire_value(&e);
1647        assert_eq!(v["type"], "StatusListFlipped");
1648        assert_eq!(v["data"]["purpose"], "revocation");
1649        assert_eq!(v["data"]["index"], 7);
1650        assert_eq!(v["data"]["revoked"], true);
1651        round_trip(&e);
1652    }
1653
1654    #[test]
1655    fn did_rotated_round_trip_with_credential_ids() {
1656        let e = AuditEvent::DidRotated(DidRotatedData {
1657            old_did: "did:key:zOld".into(),
1658            new_did: "did:key:zNew".into(),
1659            method: "did:key".into(),
1660            vmc_id: Some("urn:uuid:vmc-2".into()),
1661            role_vec_id: Some("urn:uuid:vec-2".into()),
1662            prior_role: Some("member".into()),
1663            rotation_reason: Some(DidRotationReason::Compromise),
1664        });
1665        let v = wire_value(&e);
1666        assert_eq!(v["type"], "DidRotated");
1667        assert_eq!(v["data"]["method"], "did:key");
1668        assert_eq!(v["data"]["oldDid"], "did:key:zOld");
1669        assert_eq!(v["data"]["newDid"], "did:key:zNew");
1670        assert_eq!(v["data"]["vmcId"], "urn:uuid:vmc-2");
1671        round_trip(&e);
1672    }
1673
1674    #[test]
1675    fn did_rotated_omits_credential_ids_when_none() {
1676        let e = AuditEvent::DidRotated(DidRotatedData {
1677            old_did: "did:key:zOld".into(),
1678            new_did: "did:key:zNew".into(),
1679            method: "did:key".into(),
1680            vmc_id: None,
1681            role_vec_id: None,
1682            prior_role: None,
1683            rotation_reason: None,
1684        });
1685        let v = wire_value(&e);
1686        assert!(v["data"].get("vmcId").is_none());
1687        assert!(v["data"].get("roleVecId").is_none());
1688        round_trip(&e);
1689    }
1690
1691    #[test]
1692    fn registry_sync_succeeded_round_trip() {
1693        let e = AuditEvent::RegistrySyncSucceeded(RegistrySyncOutcomeData {
1694            job_id: "11111111-1111-1111-1111-111111111111".into(),
1695            kind: "publishMember".into(),
1696            attempts: 1,
1697            last_error: None,
1698        });
1699        let v = wire_value(&e);
1700        assert_eq!(v["type"], "RegistrySyncSucceeded");
1701        assert_eq!(v["data"]["kind"], "publishMember");
1702        assert_eq!(v["data"]["attempts"], 1);
1703        assert!(
1704            v["data"].get("lastError").is_none(),
1705            "lastError should be omitted on success: {v}"
1706        );
1707        round_trip(&e);
1708    }
1709
1710    #[test]
1711    fn registry_sync_failed_round_trip_carries_last_error() {
1712        let e = AuditEvent::RegistrySyncFailed(RegistrySyncOutcomeData {
1713            job_id: "22222222-2222-2222-2222-222222222222".into(),
1714            kind: "deleteMember".into(),
1715            attempts: 17,
1716            last_error: Some("permanent: bad input".into()),
1717        });
1718        let v = wire_value(&e);
1719        assert_eq!(v["type"], "RegistrySyncFailed");
1720        assert_eq!(v["data"]["attempts"], 17);
1721        assert_eq!(v["data"]["lastError"], "permanent: bad input");
1722        round_trip(&e);
1723    }
1724
1725    #[test]
1726    fn registry_status_changed_round_trip() {
1727        let e = AuditEvent::RegistryStatusChanged(RegistryStatusChangedData {
1728            from: "active".into(),
1729            to: "degraded".into(),
1730            reason: Some("connection refused".into()),
1731        });
1732        let v = wire_value(&e);
1733        assert_eq!(v["type"], "RegistryStatusChanged");
1734        assert_eq!(v["data"]["from"], "active");
1735        assert_eq!(v["data"]["to"], "degraded");
1736        assert_eq!(v["data"]["reason"], "connection refused");
1737        round_trip(&e);
1738    }
1739
1740    #[test]
1741    fn registry_status_changed_omits_reason_when_none() {
1742        let e = AuditEvent::RegistryStatusChanged(RegistryStatusChangedData {
1743            from: "degraded".into(),
1744            to: "active".into(),
1745            reason: None,
1746        });
1747        let v = wire_value(&e);
1748        assert!(
1749            v["data"].get("reason").is_none(),
1750            "reason should be omitted when None, got {v}"
1751        );
1752        round_trip(&e);
1753    }
1754
1755    #[test]
1756    fn registry_record_policy_override_round_trip() {
1757        let e = AuditEvent::RegistryRecordPolicyOverride(RegistryRecordPolicyOverrideData {
1758            reason: "rtbf".into(),
1759            attempted_disposition: "tombstone".into(),
1760            effective_disposition: "purge".into(),
1761        });
1762        let v = wire_value(&e);
1763        assert_eq!(v["type"], "RegistryRecordPolicyOverride");
1764        assert_eq!(v["data"]["reason"], "rtbf");
1765        assert_eq!(v["data"]["attemptedDisposition"], "tombstone");
1766        assert_eq!(v["data"]["effectiveDisposition"], "purge");
1767        round_trip(&e);
1768    }
1769
1770    #[test]
1771    fn cross_community_session_minted_round_trip() {
1772        let e = AuditEvent::CrossCommunitySessionMinted(CrossCommunitySessionMintedData {
1773            outcome: "minted".into(),
1774            foreign_issuer_did: "did:webvh:peer.example.com:abc".into(),
1775            foreign_role: Some("moderator".into()),
1776            mapped_role: Some("monitor".into()),
1777            ttl_seconds: Some(900),
1778            reason: None,
1779        });
1780        let v = wire_value(&e);
1781        assert_eq!(v["type"], "CrossCommunitySessionMinted");
1782        assert_eq!(v["data"]["outcome"], "minted");
1783        assert_eq!(
1784            v["data"]["foreignIssuerDid"],
1785            "did:webvh:peer.example.com:abc"
1786        );
1787        assert_eq!(v["data"]["foreignRole"], "moderator");
1788        assert_eq!(v["data"]["mappedRole"], "monitor");
1789        assert_eq!(v["data"]["ttlSeconds"], 900);
1790        assert!(
1791            v["data"].get("reason").is_none(),
1792            "reason should be omitted on minted: {v}"
1793        );
1794        round_trip(&e);
1795    }
1796
1797    #[test]
1798    fn cross_community_session_minted_denied_carries_reason() {
1799        let e = AuditEvent::CrossCommunitySessionMinted(CrossCommunitySessionMintedData {
1800            outcome: "denied".into(),
1801            foreign_issuer_did: "did:webvh:peer.example.com:abc".into(),
1802            foreign_role: Some("admin".into()),
1803            mapped_role: None,
1804            ttl_seconds: None,
1805            reason: Some("issuer-not-recognised".into()),
1806        });
1807        let v = wire_value(&e);
1808        assert_eq!(v["data"]["outcome"], "denied");
1809        assert_eq!(v["data"]["reason"], "issuer-not-recognised");
1810        assert!(v["data"].get("mappedRole").is_none());
1811        assert!(v["data"].get("ttlSeconds").is_none());
1812        round_trip(&e);
1813    }
1814
1815    // ─── Phase 4 round-trip coverage ───────────────────
1816
1817    #[test]
1818    fn vrc_published_round_trip() {
1819        let e = AuditEvent::VrcPublished(VrcPublishedData {
1820            vrc_id: "11111111-1111-1111-1111-111111111111".into(),
1821            subject_did: Some("did:key:zSubject".into()),
1822            edge_type: "endorses".into(),
1823        });
1824        let v = wire_value(&e);
1825        assert_eq!(v["type"], "VrcPublished");
1826        assert_eq!(v["data"]["vrcId"], "11111111-1111-1111-1111-111111111111");
1827        assert_eq!(v["data"]["subjectDid"], "did:key:zSubject");
1828        assert_eq!(v["data"]["edgeType"], "endorses");
1829        round_trip(&e);
1830    }
1831
1832    #[test]
1833    fn vrc_published_omits_subject_did_when_none() {
1834        let e = AuditEvent::VrcPublished(VrcPublishedData {
1835            vrc_id: "id".into(),
1836            subject_did: None,
1837            edge_type: "reports-to".into(),
1838        });
1839        let v = wire_value(&e);
1840        assert!(v["data"].get("subjectDid").is_none());
1841        round_trip(&e);
1842    }
1843
1844    #[test]
1845    fn vrc_revoked_round_trip() {
1846        let e = AuditEvent::VrcRevoked(VrcRevokedData {
1847            vrc_id: "id".into(),
1848            revoked_by: "issuer".into(),
1849        });
1850        let v = wire_value(&e);
1851        assert_eq!(v["type"], "VrcRevoked");
1852        assert_eq!(v["data"]["revokedBy"], "issuer");
1853        round_trip(&e);
1854    }
1855
1856    #[test]
1857    fn personhood_asserted_round_trip() {
1858        let e = AuditEvent::PersonhoodAsserted(PersonhoodAssertedData {
1859            vmc_id: "vmc-7".into(),
1860            asserted_at: "2026-05-14T10:00:00Z".into(),
1861        });
1862        let v = wire_value(&e);
1863        assert_eq!(v["type"], "PersonhoodAsserted");
1864        assert_eq!(v["data"]["vmcId"], "vmc-7");
1865        assert_eq!(v["data"]["assertedAt"], "2026-05-14T10:00:00Z");
1866        round_trip(&e);
1867    }
1868
1869    #[test]
1870    fn personhood_revoked_round_trip() {
1871        let e = AuditEvent::PersonhoodRevoked(PersonhoodRevokedData {
1872            vmc_id: Some("vmc-8".into()),
1873            reason: "admin".into(),
1874        });
1875        let v = wire_value(&e);
1876        assert_eq!(v["type"], "PersonhoodRevoked");
1877        assert_eq!(v["data"]["vmcId"], "vmc-8");
1878        assert_eq!(v["data"]["reason"], "admin");
1879        round_trip(&e);
1880    }
1881
1882    #[test]
1883    fn personhood_revoked_omits_vmc_id_when_refuse_arm() {
1884        // `refuse` arm of on_personhood_fail doesn't re-mint
1885        // a VMC, so vmc_id is None.
1886        let e = AuditEvent::PersonhoodRevoked(PersonhoodRevokedData {
1887            vmc_id: None,
1888            reason: "renewal-policy".into(),
1889        });
1890        let v = wire_value(&e);
1891        assert!(v["data"].get("vmcId").is_none());
1892        assert_eq!(v["data"]["reason"], "renewal-policy");
1893        round_trip(&e);
1894    }
1895
1896    #[test]
1897    fn custom_endorsement_issued_round_trip() {
1898        let e = AuditEvent::CustomEndorsementIssued(CustomEndorsementIssuedData {
1899            endorsement_id: "end-1".into(),
1900            endorsement_type: "https://example.com/v1/skills/rust".into(),
1901            status_list_index: 42,
1902        });
1903        let v = wire_value(&e);
1904        assert_eq!(v["type"], "CustomEndorsementIssued");
1905        assert_eq!(
1906            v["data"]["endorsementType"],
1907            "https://example.com/v1/skills/rust"
1908        );
1909        assert_eq!(v["data"]["statusListIndex"], 42);
1910        round_trip(&e);
1911    }
1912
1913    #[test]
1914    fn custom_endorsement_revoked_round_trip() {
1915        let e = AuditEvent::CustomEndorsementRevoked(CustomEndorsementRevokedData {
1916            endorsement_id: "end-1".into(),
1917            endorsement_type: "https://example.com/v1/skills/rust".into(),
1918        });
1919        let v = wire_value(&e);
1920        assert_eq!(v["type"], "CustomEndorsementRevoked");
1921        round_trip(&e);
1922    }
1923
1924    #[test]
1925    fn endorsement_type_registered_round_trip() {
1926        let e = AuditEvent::EndorsementTypeRegistered(EndorsementTypeRegisteredData {
1927            type_uri: "https://example.com/v1/skills/rust".into(),
1928            description: Some("Rust proficiency endorsement".into()),
1929        });
1930        let v = wire_value(&e);
1931        assert_eq!(v["type"], "EndorsementTypeRegistered");
1932        assert_eq!(v["data"]["typeUri"], "https://example.com/v1/skills/rust");
1933        assert_eq!(v["data"]["description"], "Rust proficiency endorsement");
1934        round_trip(&e);
1935    }
1936
1937    #[test]
1938    fn endorsement_type_registered_omits_description_when_none() {
1939        let e = AuditEvent::EndorsementTypeRegistered(EndorsementTypeRegisteredData {
1940            type_uri: "https://example.com/v1/x".into(),
1941            description: None,
1942        });
1943        let v = wire_value(&e);
1944        assert!(v["data"].get("description").is_none());
1945        round_trip(&e);
1946    }
1947
1948    #[test]
1949    fn endorsement_type_deleted_round_trip() {
1950        let e = AuditEvent::EndorsementTypeDeleted(EndorsementTypeDeletedData {
1951            type_uri: "https://example.com/v1/skills/rust".into(),
1952            live_endorsements_at_delete: 0,
1953        });
1954        let v = wire_value(&e);
1955        assert_eq!(v["type"], "EndorsementTypeDeleted");
1956        round_trip(&e);
1957    }
1958
1959    #[test]
1960    fn variant_discriminator_strings() {
1961        let cases: Vec<(AuditEvent, &str)> = vec![
1962            (
1963                AuditEvent::CommunityInstalled(CommunityInstalledData {
1964                    community_did: "did:webvh:x".into(),
1965                    install_token_jti: "j".into(),
1966                }),
1967                "CommunityInstalled",
1968            ),
1969            (
1970                AuditEvent::EmergencyBootstrapInvoked(EmergencyBootstrapData {
1971                    operator_hostname: "h".into(),
1972                    invoked_at: Utc::now(),
1973                }),
1974                "EmergencyBootstrapInvoked",
1975            ),
1976            (
1977                AuditEvent::AdminPasskeyRegistered(AdminPasskeyData {
1978                    credential_id_hex: "0".into(),
1979                    label: "x".into(),
1980                    transports: vec![],
1981                }),
1982                "AdminPasskeyRegistered",
1983            ),
1984            (
1985                AuditEvent::AdminPasskeyRevoked(AdminPasskeyData {
1986                    credential_id_hex: "0".into(),
1987                    label: "x".into(),
1988                    transports: vec![],
1989                }),
1990                "AdminPasskeyRevoked",
1991            ),
1992            (
1993                AuditEvent::ConfigChanged(ConfigChangedData {
1994                    changes: vec![],
1995                    requires_restart: false,
1996                }),
1997                "ConfigChanged",
1998            ),
1999            (
2000                AuditEvent::ConfigReloaded(ConfigReloadedData {
2001                    keys_reloaded: vec![],
2002                }),
2003                "ConfigReloaded",
2004            ),
2005            (
2006                AuditEvent::RestartRequested(RestartRequestedData {
2007                    drain_timeout_seconds: 0,
2008                }),
2009                "RestartRequested",
2010            ),
2011            (
2012                AuditEvent::CommunityProfileUpdated(CommunityProfileUpdatedData {
2013                    fields_changed: vec![],
2014                    changes: vec![],
2015                }),
2016                "CommunityProfileUpdated",
2017            ),
2018            (
2019                AuditEvent::AuditKeyRotated(AuditKeyRotatedData {
2020                    previous_key_id: "p".into(),
2021                    new_key_id: "n".into(),
2022                    rotation_reason: RotationReason::Initial,
2023                }),
2024                "AuditKeyRotated",
2025            ),
2026            (
2027                AuditEvent::PolicyUploaded(PolicyUploadedData {
2028                    policy_id: "p".into(),
2029                    purpose: "join".into(),
2030                    sha256: "x".into(),
2031                    version: 1,
2032                }),
2033                "PolicyUploaded",
2034            ),
2035            (
2036                AuditEvent::PolicyActivated(PolicyActivatedData {
2037                    policy_id: "p".into(),
2038                    purpose: "join".into(),
2039                    sha256: "x".into(),
2040                    previous_policy_id: None,
2041                }),
2042                "PolicyActivated",
2043            ),
2044            (
2045                AuditEvent::VmcIssued(CredentialIssuedData {
2046                    credential_id: "id".into(),
2047                    credential_type: "VerifiableMembershipCredential".into(),
2048                    valid_from: "vf".into(),
2049                    valid_until: "vu".into(),
2050                    status_list_index: None,
2051                }),
2052                "VmcIssued",
2053            ),
2054            (
2055                AuditEvent::VecIssued(CredentialIssuedData {
2056                    credential_id: "id".into(),
2057                    credential_type: "VerifiableEndorsementCredential".into(),
2058                    valid_from: "vf".into(),
2059                    valid_until: "vu".into(),
2060                    status_list_index: None,
2061                }),
2062                "VecIssued",
2063            ),
2064            (
2065                AuditEvent::MembershipRenewed(MembershipRenewedData {
2066                    vmc_id: "v".into(),
2067                    role_vec_id: "r".into(),
2068                    personhood_changed: false,
2069                }),
2070                "MembershipRenewed",
2071            ),
2072            (
2073                AuditEvent::StatusListFlipped(StatusListFlippedData {
2074                    purpose: "revocation".into(),
2075                    index: 0,
2076                    revoked: true,
2077                }),
2078                "StatusListFlipped",
2079            ),
2080            (
2081                AuditEvent::DidRotated(DidRotatedData {
2082                    old_did: "o".into(),
2083                    new_did: "n".into(),
2084                    method: "did:key".into(),
2085                    vmc_id: None,
2086                    role_vec_id: None,
2087                    prior_role: None,
2088                    rotation_reason: None,
2089                }),
2090                "DidRotated",
2091            ),
2092            (
2093                AuditEvent::RegistryStatusChanged(RegistryStatusChangedData {
2094                    from: "active".into(),
2095                    to: "degraded".into(),
2096                    reason: None,
2097                }),
2098                "RegistryStatusChanged",
2099            ),
2100            (
2101                AuditEvent::RegistrySyncSucceeded(RegistrySyncOutcomeData {
2102                    job_id: "j".into(),
2103                    kind: "publishMember".into(),
2104                    attempts: 1,
2105                    last_error: None,
2106                }),
2107                "RegistrySyncSucceeded",
2108            ),
2109            (
2110                AuditEvent::RegistrySyncFailed(RegistrySyncOutcomeData {
2111                    job_id: "j".into(),
2112                    kind: "deleteMember".into(),
2113                    attempts: 1,
2114                    last_error: Some("x".into()),
2115                }),
2116                "RegistrySyncFailed",
2117            ),
2118            (
2119                AuditEvent::RegistryRecordPolicyOverride(RegistryRecordPolicyOverrideData {
2120                    reason: "rtbf".into(),
2121                    attempted_disposition: "tombstone".into(),
2122                    effective_disposition: "purge".into(),
2123                }),
2124                "RegistryRecordPolicyOverride",
2125            ),
2126            (
2127                AuditEvent::CrossCommunitySessionMinted(CrossCommunitySessionMintedData {
2128                    outcome: "minted".into(),
2129                    foreign_issuer_did: "did:webvh:peer".into(),
2130                    foreign_role: Some("moderator".into()),
2131                    mapped_role: Some("monitor".into()),
2132                    ttl_seconds: Some(900),
2133                    reason: None,
2134                }),
2135                "CrossCommunitySessionMinted",
2136            ),
2137            (
2138                AuditEvent::VrcPublished(VrcPublishedData {
2139                    vrc_id: "id".into(),
2140                    subject_did: Some("did:key:zX".into()),
2141                    edge_type: "endorses".into(),
2142                }),
2143                "VrcPublished",
2144            ),
2145            (
2146                AuditEvent::VrcRevoked(VrcRevokedData {
2147                    vrc_id: "id".into(),
2148                    revoked_by: "admin".into(),
2149                }),
2150                "VrcRevoked",
2151            ),
2152            (
2153                AuditEvent::PersonhoodAsserted(PersonhoodAssertedData {
2154                    vmc_id: "v".into(),
2155                    asserted_at: "2026-05-14T10:00:00Z".into(),
2156                }),
2157                "PersonhoodAsserted",
2158            ),
2159            (
2160                AuditEvent::PersonhoodRevoked(PersonhoodRevokedData {
2161                    vmc_id: Some("v".into()),
2162                    reason: "self".into(),
2163                }),
2164                "PersonhoodRevoked",
2165            ),
2166            (
2167                AuditEvent::CustomEndorsementIssued(CustomEndorsementIssuedData {
2168                    endorsement_id: "end".into(),
2169                    endorsement_type: "https://x/v1/t".into(),
2170                    status_list_index: 0,
2171                }),
2172                "CustomEndorsementIssued",
2173            ),
2174            (
2175                AuditEvent::CustomEndorsementRevoked(CustomEndorsementRevokedData {
2176                    endorsement_id: "end".into(),
2177                    endorsement_type: "https://x/v1/t".into(),
2178                }),
2179                "CustomEndorsementRevoked",
2180            ),
2181            (
2182                AuditEvent::EndorsementTypeRegistered(EndorsementTypeRegisteredData {
2183                    type_uri: "https://x/v1/t".into(),
2184                    description: None,
2185                }),
2186                "EndorsementTypeRegistered",
2187            ),
2188            (
2189                AuditEvent::EndorsementTypeDeleted(EndorsementTypeDeletedData {
2190                    type_uri: "https://x/v1/t".into(),
2191                    live_endorsements_at_delete: 0,
2192                }),
2193                "EndorsementTypeDeleted",
2194            ),
2195            (
2196                AuditEvent::WebsiteFileWritten(WebsiteFileWrittenData {
2197                    path: "index.html".into(),
2198                    size_bytes: 42,
2199                    sha256: "deadbeef".into(),
2200                }),
2201                "WebsiteFileWritten",
2202            ),
2203            (
2204                AuditEvent::WebsiteFileDeleted(WebsiteFileDeletedData {
2205                    path: "old.html".into(),
2206                }),
2207                "WebsiteFileDeleted",
2208            ),
2209            (
2210                AuditEvent::WebsiteBundleDeployed(WebsiteBundleDeployedData {
2211                    bundle_sha256: "deadbeef".into(),
2212                    bundle_size_bytes: 1024,
2213                    deploy_mode: "managed".into(),
2214                    target_generation: 7,
2215                    pruned_generations: 2,
2216                }),
2217                "WebsiteBundleDeployed",
2218            ),
2219            (
2220                AuditEvent::WebsiteGenerationRolledBack(WebsiteGenerationRolledBackData {
2221                    from_generation: 7,
2222                    to_generation: 5,
2223                }),
2224                "WebsiteGenerationRolledBack",
2225            ),
2226            (
2227                AuditEvent::AdminUiServed(AdminUiServedData {
2228                    index_sha256: "deadbeef".into(),
2229                    file_count: 3,
2230                    mode: "embedded".into(),
2231                    daemon_version: None,
2232                }),
2233                "AdminUiServed",
2234            ),
2235        ];
2236        for (event, expected) in cases {
2237            let v = serde_json::to_value(&event).unwrap();
2238            assert_eq!(v["type"], expected, "discriminator drift for {expected}");
2239        }
2240    }
2241}