Docs / Security and data
Audit trail and evidence
What is recorded when state changes, how decisions and reference responses stay tamper-evident, and who may read the audit log.
State changes that matter write an audit event in the same database transaction as the change, so the change and its record commit or fail together. Code: domains/platform/audit.ts, staff/audit.service.ts.
What an event holds#
Actor user and organization, action (dotted name such as org.approved), target type and id, a metadata object, free notes and a SHA-256 hash of the client IP truncated to 32 hex characters. Events carry ids and labels, never personal data values. When staff act as another user, actorUserId is the impersonated user and impersonatedByUserId names the staff member.
Actions recorded today#
| Area | Actions |
|---|---|
| Tenants | org.created, org.approved, org.auto_approved, org.denied, org.suspended, org.unsuspended |
| Users | user.created, user.role_changed, user.locked, user.unlocked, user.account_deleted, user.impersonation_started, user.impersonation_ended, user.data_exported, user.profile_updated |
| Decisions | application.decided_approved, application.decided_countered, application.declined |
| Documents | document.uploaded, document.deleted, document.accessed |
| Invitations | supplier.invitation_queued, supplier.invitation_accepted, customer.invitation_queued |
| Connectors | connector.registered, connector.installed, connector.uninstalled, connector_draft.accepted, connector_draft.rejected |
| Agents | mcp.read, mcp.decision_proposed |
| Audit | audit.exported (the export audits itself) |
Reading the log#
Every event says who really acted in actorKind: user, agent, system or impersonated_user. For an AI assistant using MCP the actor stays the real person and the event adds agentClientId (the OAuth client id), agentName (a label derived from it, such as the host of a client-metadata URL), agentSessionId (the OAuth grant) and agentTool. The audit writer fills these from the request context around every MCP tool call, so events written by shared services (a reference reminder) carry them too; no PII is stored in them. Reads of data by an agent record who looked, never what.
Staff only: GET /v1/internal/audit (filters kind and agent as well) (filtered, paged, newest first), GET /v1/internal/audit/recent-actors and GET /v1/internal/audit/export (CSV, capped). All answer 403 to anyone who is not a staff session on a platform role, re-checked against the database. A failed query is a 503, not an empty list.
Evidence integrity#
- A reference response claims the current token, request version, unexpired link and unanswered state in one transaction, so only one submission wins; a loser gets 409. It writes an immutable revision with the contact details at response time, the answers, the attestation text and a signature. A re-review rotates the link and keeps earlier revisions.
- A decision, its audit event and the applicant notification commit in one transaction. A second decision on a finished application is a 409.
- Documents are never hard-deleted by users: removal sets
removedAtand keeps the stored object. Only the uploader's own unattached files can be removed, and only before a final decision. Signed originals, message attachments and reference evidence cannot be removed. - Downloads issue a short-lived signed URL after authorization. Storage paths are not returned to browsers.
- A response accepted from a connector draft is stored with source
email_unsigned(no signer, no signature) and is labelled as weaker evidence than a signed secure-link response.