Skip to main content

vti_common/audit/
event.rs

1//! [`AuditEvent`] — the tagged enum of every audit-log variant.
2//!
3//! Ships the **Phase-0 vocabulary** matching spec §11.4. Phase-1+
4//! variants land alongside their owning features (join requests,
5//! members, policies, registry, VRC, etc.) and follow the same
6//! pattern: one variant per semantically distinct event, with a
7//! purpose-built data struct.
8//!
9//! ## Wire contract
10//!
11//! - Tagged form `#[serde(tag = "type", content = "data")]` so
12//!   external consumers (SIEM, later webhooks) discriminate on the
13//!   `type` field. **Variant identifiers are part of the wire
14//!   contract — don't rename them without bumping
15//!   `EVENT_VERSION`.**
16//! - Data structs use `#[serde(rename_all = "camelCase")]` for
17//!   downstream tooling friendliness. Field names are also wire
18//!   contract.
19//!
20//! ## Sensitive-field redaction
21//!
22//! [`ConfigChange::redact_if`] walks a [`ConfigChangedData`] and
23//! masks `old_value` / `new_value` for any key matched by the caller-
24//! supplied sensitivity predicate. The emitter (config endpoint
25//! handlers, M0.8) calls this **before** persisting the event so
26//! sensitive values never reach the audit keyspace in cleartext.
27
28use chrono::{DateTime, Utc};
29use serde::{Deserialize, Serialize};
30use serde_json::Value;
31
32use super::key_store::RotationReason;
33
34/// Marker used in place of redacted config values. Distinguishable
35/// from a JSON null / empty string by callers introspecting an
36/// archived audit row.
37pub const REDACTED_MARKER: &str = "<redacted>";
38
39// ---------------------------------------------------------------------------
40// AuditEvent
41// ---------------------------------------------------------------------------
42
43/// Audit-event payload. Tagged on `type` with the variant name and
44/// the variant's data under `data`. Phase-0 vocabulary only;
45/// Phase-1+ adds variants alongside the features that emit them.
46#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
47#[serde(tag = "type", content = "data")]
48pub enum AuditEvent {
49    /// Bootstrap completed — the first admin DID was written into the
50    /// ACL and the install carve-out was permanently closed.
51    CommunityInstalled(CommunityInstalledData),
52
53    /// `vtc admin emergency-bootstrap` was invoked with a valid
54    /// master-seed mnemonic, re-opening the install carve-out exactly
55    /// once. Loud event — surfaced prominently in diagnostics on next
56    /// daemon start so a forgotten emergency action is impossible to
57    /// miss.
58    EmergencyBootstrapInvoked(EmergencyBootstrapData),
59
60    /// A passkey was registered against an admin DID (initial enrol
61    /// at install **or** a subsequent additional-device enrolment).
62    AdminPasskeyRegistered(AdminPasskeyData),
63
64    /// A passkey was revoked from an admin DID. The CAS check that
65    /// refuses to leave zero passkeys runs *before* the event is
66    /// emitted, so any persisted `AdminPasskeyRevoked` leaves at
67    /// least one passkey behind.
68    AdminPasskeyRevoked(AdminPasskeyData),
69
70    /// One or more runtime configuration keys were modified via
71    /// `PATCH /v1/admin/config`. Per-key sensitivity is honoured —
72    /// values for keys flagged sensitive are redacted via
73    /// [`ConfigChange::redact_if`] before persistence.
74    ConfigChanged(ConfigChangedData),
75
76    /// `POST /v1/admin/config/reload` applied hot-reloadable settings
77    /// in-place. Lists which keys actually re-applied (a key that
78    /// was unchanged-or-already-active doesn't appear).
79    ConfigReloaded(ConfigReloadedData),
80
81    /// `POST /v1/admin/config/restart` initiated graceful shutdown.
82    /// Emitted **before** the process exits so the next-boot replay
83    /// can correlate the restart with the prior config patches that
84    /// triggered it.
85    RestartRequested(RestartRequestedData),
86
87    /// `PUT /v1/community/profile` updated one or more profile
88    /// fields. Records which fields changed by name; the values
89    /// themselves stay out of the audit log (profile data isn't
90    /// security-sensitive by nature, but keeping the event small
91    /// is operator-friendly).
92    CommunityProfileUpdated(CommunityProfileUpdatedData),
93
94    /// The community `audit_key` was rotated. Emitted under the
95    /// **new** key (the rotation itself is what creates the new
96    /// epoch), so an investigator can find the row by querying the
97    /// `audit_by_type` index without needing to walk the prior
98    /// epoch.
99    AuditKeyRotated(AuditKeyRotatedData),
100
101    /// `PATCH /v1/members/{did}` updated profile or non-role
102    /// metadata on a member's record. Lists the field names that
103    /// changed; values stay out of the envelope.
104    MemberUpdated(MemberUpdatedData),
105
106    /// `PATCH /v1/members/{did}` reassigned the member's role.
107    /// Distinct event from `MemberUpdated` because role changes
108    /// are security-significant — SIEM filters key on this
109    /// variant separately. Admin promotion uses
110    /// [`AdminPromoted`] instead (spec §10.4 keeps the two
111    /// paths separate).
112    RoleChanged(RoleChangedData),
113
114    /// `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}