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}