What this service records when something security-relevant happens, what it deliberately does not record, and what happens when the audit write itself fails.
The list below is generated from nothing — it is maintained by hand alongside
src/common/audit/audit-taxonomy.ts,
and audit-taxonomy.spec.ts fails
the build if the two drift apart. An event that exists in code but not in this
document, or whose write-failure behaviour differs from the row below, is a test
failure rather than a documentation bug.
| Store | Table | Holds |
|---|---|---|
audit_log |
AuditLog |
Authenticated mutations: who did what, to which resource, under which tenant. |
auth_audit_event |
AuthAuditEvent |
Authentication attempts, keyed by a hashed wallet. No addresses, no client identifiers. |
verification_event_log |
VerificationEventLog |
Public verification outcomes, keyed by proof, with a salted metadata hash. |
The three stores exist because their privacy budgets differ. AuditLog rows
belong to an identified tenant and can name the actor. Authentication happens
before anyone is identified, so its rows carry a hash and nothing else.
Verification is unauthenticated and public, so its rows carry no actor at all —
see ADR 0004.
Failure is the behaviour of the caller's mutation when the audit write
fails: fail_closed propagates the error and the action fails; fail_open logs
and continues.
| Event type | Domain | Store | Actor | Outcomes | Tenant context | Failure |
|---|---|---|---|---|---|---|
authentication.challenge_created |
authentication | auth_audit_event |
wallet | success | wallet hash | fail_open |
authentication.challenge_verified |
authentication | auth_audit_event |
wallet | success | wallet hash | fail_open |
authentication.signature_invalid |
authentication | auth_audit_event |
wallet | denied | wallet hash | fail_open |
authentication.challenge_expired |
authentication | auth_audit_event |
wallet | denied | wallet hash | fail_open |
authentication.challenge_replayed |
authentication | auth_audit_event |
wallet | denied | wallet hash | fail_open |
authorization.rate_limited |
authorization | auth_audit_event |
wallet | denied | wallet hash | fail_open |
authorization.api_key_authenticated |
authorization | audit_log |
api_key | success | metadata.organizationId |
fail_open |
issuer.created |
issuer | audit_log |
user | success | metadata.organizationId |
fail_closed |
issuer.metadata_updated |
issuer | audit_log |
user | success | actor | fail_closed |
issuer.status_updated |
issuer | audit_log |
user | success | actor | fail_closed |
issuer.status_synced |
issuer | audit_log |
user | success | actor | fail_closed |
proof.verification_recorded |
proof | verification_event_log |
system | success, denied | proof | fail_open |
api_key.created |
api_key | audit_log |
user | success | metadata.organizationId |
fail_closed |
api_key.rotated |
api_key | audit_log |
user | success | metadata.organizationId |
fail_closed |
api_key.revoked |
api_key | audit_log |
user | success | metadata.organizationId |
fail_closed |
webhook.delivery_replayed |
webhook | audit_log |
user | success | actor | fail_closed |
operator.organization_created |
operator | audit_log |
user | success | resource | fail_closed |
operator.organization_updated |
operator | audit_log |
user | success | resource | fail_closed |
operator.payment_classification_updated |
operator | audit_log |
user | success | actor | fail_closed |
operator.trusted_source_created |
operator | audit_log |
user | success | actor | fail_closed |
operator.trusted_source_updated |
operator | audit_log |
user | success | actor | fail_closed |
operator.trusted_source_deleted |
operator | audit_log |
user | success | actor | fail_closed |
The stable type is the name to use in alerts, runbooks and reports. It is not
always the string in the action column: the earliest audit writers used
("CREATE", "Issuer") while later ones used ("api_key.created", "api_key"),
and rewriting the older rows would break every query already written against
them. resolveAuditEventType({ action, resourceType }) maps a persisted row to
its stable type, and the pair — not the action alone — is the identity, so
("CREATE", "Issuer") and ("CREATE", "Organization") stay distinct.
The same split runs through actorType: the early writers persist "User",
the later ones "user". Both name the same principal, so
normalizeAuditActorType folds them together on read. A query that filters on
one spelling alone silently drops half the trail — use the helper, or match
case-insensitively.
Normalising the persisted action and actorType values in the database is a
migration, not a test change, and is deliberately out of scope here.
None of the following may appear in any audit record, in any store, under any
key. audit-redaction.ts enforces
this by key name and by value shape, and
audit-event-matrix.spec.ts
scans the record every audited action actually produces.
- Session tokens, API key secrets, and any other bearer credential.
- Signatures, signing keys, and challenge text — all of it replayable material.
- Proof bodies and credential payloads.
- Exact income figures and any individual payment amount.
- Payment history.
- Wallet addresses, in the clear.
- Client identifiers: IP addresses and user agents.
- Personal data: email addresses, phone numbers, tax identifiers.
Two shapes are sanctioned in place of the above:
- Hashes. A key ending in
Hash/hashmay carry a SHA-256 digest of a value that could not otherwise be recorded —walletHash,sourceAddressHash,clientMetadataHash. This keeps rows joinable for abuse detection without keeping the identifier. - Declared public identifiers. An issuer's Stellar account is public
registry data and is recorded in the clear on
issuer.created. Every such field is named in the taxonomy'spublicIdentifierFields; nothing is exempt by accident.
The scanner also rejects any string that looks like a secret regardless of its key — a Stellar address or seed, a JWT, a PEM private key, an IPv4 literal, or a base64 blob of 43 characters or more (the encoded length of this service's own API key secret).
Every critical mutation has an explicit answer to "what if the audit write fails?", asserted in the matrix suite.
Fail-closed — every authenticated mutation: API key issue, rotate and revoke; issuer registration, metadata, status and sync; webhook replay; organisation create and update; payment reclassification; trusted-source create, update and delete. If the audit row cannot be written, the request fails. An untraceable key revocation is worse than a failed one, and the caller can retry.
Fail-open — authentication attempts, API key presentation, and public
verification. These paths are reachable without credentials, so failing them on
an audit outage would hand any client that can degrade the audit store a way to
take authentication offline. The write is attempted, the failure is logged at
warn, and the request proceeds.
The consequence is worth stating plainly: the authentication and verification
trails are best-effort. Alerting that counts authentication failures must
treat a gap as inconclusive, not as an absence of attempts. The mutation trail
in AuditLog is not best-effort, and a gap there means the mutation did not
happen.
Recorded here rather than left implicit:
- Authorization denials at the guards. A rejected role check
(
role.guard.ts) or scope check (scopes.guard.ts) is not audited. Auditing it would let an unauthenticated caller drive unbounded writes into the audit store, which is the same trade the fail-open paths make in the other direction. Rate-limited authentication denials are recorded, asauthorization.rate_limited, because the rate limiter bounds them by construction. Denials are visible in the HTTP metrics (observability) in the meantime. - Background jobs. Anchoring and retention workers do not write audit events; their evidence is the outbox row and the structured job log.
AuditLog and AuthAuditEvent are pruned by the retention job
(data retention); VerificationEventLog rows carry their
own retainUntil. Retention is a separate promise from completeness: an event
absent because it aged out is not the same as an event that was never written,
and only the first is expected.
- Write the record from the service, following the shape of the nearest existing writer.
- Declare it in
audit-taxonomy.tswith its actor types, outcomes, tenant source and write-failure behaviour. - Add a scenario to
audit-event-matrix.spec.tsthat drives the real service. The coverage assertion fails until you do. - Add the row to the table above. The documentation test fails until you do.