Audit writes are two-tier (sync + async)
The property
The audit service exposes two write paths: a synchronous path for sensitive events (privilege changes, impersonation, credential mutations) and an asynchronous path for routine events. The shape is documented in Current state → Audit and observability.
Why two tiers
Audit is a SOC 2 control. The control objective requires that any sensitive action is recorded in an immutable trail before the action is considered complete. If the audit write fails, the action fails — there is no "succeeded but unrecorded" outcome.
That requirement only applies to sensitive operations. The platform also generates a large volume of routine, low-sensitivity events (login, page view, low-impact CRUD on owned data). Requiring synchronous audit writes for the routine class would couple every routine endpoint's tail latency to MongoDB write-acknowledgement latency. That's a real UX cost — a 50ms tail latency on every action — for no compliance benefit.
The two-tier split lets sensitive actions take the latency hit for correctness, while routine events flow through a queue that doesn't gate the response.
Why not one tier (all sync)
- Routine event volume is high. Sync writes would pin tail latency to MongoDB acknowledge time on every action.
- Routine events do not need write-before-response — their compliance value is "we have a record," not "the response is contingent on the record."
- Backpressure on the routine path would degrade UX without improving the control posture.
Why not one tier (all async)
- Sensitive operations need the write durably committed before the response so an attacker who exploits a partial-failure window cannot complete a privilege change with no audit record.
- Returning a success response before the audit write means a network or pod failure between response and audit write loses the event silently. For a sensitive operation, that's a control failure.
Implementation seam
The decision is owned by the audit service surface (libs/api/services-api/src/lib/core/audit/audit.service.ts:46). Callers invoke a single emit(input, explicitContext?) method; the sync/async routing is automatic based on SENSITIVE_AUDIT_EVENT_TYPES.has(event_type) && actor !== SYSTEM. The sensitivity classification lives in the type registry, not at each callsite — new sensitive event types are added to the set, and every caller inherits the right tier without code change.
Consequences
- The audit surface is a single
emit()method. Callers cannot accidentally pick the wrong tier — the routing is a property of the event type, not the call site. - Adding a new sensitive event type means adding it to
SENSITIVE_AUDIT_EVENT_TYPESand every emit of that type inherits the sync path. New compliance regimes (GDPR-class, HIPAA-class) extend the set rather than rewriting the audit path. - Sensitive audit failures abort the originating business operation by throwing. Callers performing sensitive operations must treat
Audit write failedas a fail-closed signal — there is no retry path inside the audit service. - Routine audit failures are logged but do not propagate. Operational visibility for routine audit health depends on structured-log keys feeding CloudWatch alarms — not on caller awareness. A queue or processor outage will not surface as user-visible errors.
- System-actor events (cron, queue consumers) always take the async path regardless of event type. Routine credential refresh must not create write contention on the audit collection; the explicit actor-type check at the audit service is what makes that guarantee.
Related
- Current state → Audit and observability
- OpenSpec:
openspec/specs/audit-service/spec.md