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