Skip to content

Audit event schema

Schema version: 1

Every security-relevant action in Praxis emits an audit event. Events land in the built-in audit_events table and fan out to any configured external sinks (Settings → Audit Export).

This document is the wire format contract. Breaking changes bump schema_version.


Wire format

{
"schema_version": 1,
"event_uuid": "c9f1…",
"timestamp": "2026-04-22T00:00:00.000Z",
"action": "session.open",
"outcome": "success",
"actor": {
"user_id": 42,
"username": "alice",
"ip": "10.0.0.5"
},
"target": {
"kind": "session",
"system_id": 7,
"id": "123"
},
"context": { ... per-action payload ... }
}

Fields:

FieldTypeNotes
schema_versionintBumped on breaking changes. Consumers should reject unknown versions.
event_uuidstringStable ID. Dedup key for at-least-once sinks.
timestampISO 8601 UTCEvent time at the Praxis server.
actiondotted stringSee vocabulary below.
outcomesuccess / failure / denieddenied = permission gate rejected; failure = ran but errored.
actorobjectUser who triggered the action. All fields nullable.
targetobjectWhat the action applied to. All fields nullable.
contextobjectAction-specific payload. Stable within a given action+schema_version.

Action vocabulary

Sessions

ActionWhen it firescontext keys
session.openInteractive terminal session establishedlogin, fleet_role, cert_serial, ttl_s
session.closeUser clicked Disconnect or remote EOFreason, login
session.idle_killIdle timeout sweeper closed the sessionreason, login
session.max_durationCert TTL hit, hard closereason, login

Command execution

Actionoutcomecontext keys
command.execsuccess / failurecommand, exit_code, execution_time_ms, bypass_validation
command.execdeniedreason_code, reason

File transfer

ActionWhen it firescontext keys
file.uploadWeb UI → host SFTP uploadlogin, size_bytes, sha256, local_filename, error?
file.downloadHost → web UI SFTP downloadlogin, size_bytes, sha256, error?
file.mkdirNew remote directorylogin, error?
file.unlinkRemote file/dir deletionlogin, error?

target.kind is path and target.id is the remote path.

Access bindings + requests

Actioncontext keys
binding.createfleet_role_id, subject_user_id, subject_app_role_id, scope_group_id, scope_smart_group_id, expires_at
binding.deleteSnapshot of the deleted binding shape
access_request.createfleet_role_id, scope_group_id, scope_smart_group_id, duration_seconds, justification
access_request.approverequested_by, fleet_role_id, resulting_binding_id, duration_seconds, comment
access_request.rejectrequested_by, comment
access_request.revoke

Authentication

Actioncontext keys
totp.step_upmethod = totp or recovery

Secrets, Vault & PKI

Sensitive secret and PKI actions emit unified audit events so secret access and CA lifecycle changes reach external sinks. context carries only locators and non-secret metadata — never secret values, passwords, private keys, certificate material, tokens, or raw Vault exception text.

ssh.user_cert.sign fires when Vault signs a short-lived SSH user certificate for host access, at mint time (independent of whether the subsequent SSH connection succeeds), for the actor-attributed paths: interactive sessions (purpose=session) and web file transfer (purpose=file_transfer). Backend SSH that reuses the pooled connection helper (e.g. command execution) has no single user actor at the mint point; that host access is audited via its parent command.exec event instead. Note session.open separately records the same cert_serial as part of the session lifecycle.

ActionWhen it firescontext keys
credential.secret.revealA credential’s secret is revealed from Vaultname, auth_method, vault_path
credential.createNew credential created (managed or linked)name, auth_method, vault_path, mode
credential.updateCredential metadata and/or its Vault secret updatedname, auth_method, secret_updated (bool)
credential.deleteCredential deleted (Vault secret + DB metadata)name, auth_method
vault.secret.readDirect Vault secret read (incl. specific version)vault_path, version?
vault.secret.writeDirect Vault secret create/updatevault_path
vault.secret.password_updatePassword field updated within a Vault secretvault_path, username
vault.secret.deleteDirect Vault secret deletedvault_path
ssh.user_cert.signShort-lived SSH user cert minted for host accesslogin, ttl_s, cert_serial, purpose
ssh.ca.rotateSSH CA keypair regenerated in Vault
ssh.ca.revoke_user_certsSSH CA identifier bumped + pooled sessions dropped

target.kind is credential (with target.id = credential id), vault_secret (with target.id = vault path), system (for ssh.user_cert.sign when the target system is known; otherwise ssh_user_cert), or ssh_ca.

Airgap signing keys and import trust

Airgap bundle signing-key and import trust-pin lifecycle events carry only public identifiers and status metadata. Context must never include armored key bodies, private key material, Vault paths, bundle tar contents, or raw GPG/Vault exception text.

ActionWhen it firestarget.kindcontext keys
airgap.signing_key.createdInitial instance bundle signing key is bootstrappedairgap_signing_keykey_id, fingerprint, key_uid, status
airgap.signing_key.rotatedActive bundle signing key is rotated: old active becomes rotating_out, new key becomes activeairgap_signing_keyold_key_id, old_fingerprint, old_status, new_key_id, new_fingerprint, new_status
airgap.signing_key.retiredA rotating_out bundle signing key is retiredairgap_signing_keykey_id, fingerprint, status
airgap.import_trust.addedImport-side public key trust pin is addedairgap_import_trustkey_id, fingerprint, key_uid
airgap.import_trust.removedImport-side public key trust pin is soft-deletedairgap_import_trustkey_id, fingerprint, key_uid

Compliance remediation requests

Non-executing workflow that captures operator intent to remediate a failing compliance evidence row plus the approval-gate state. Approval flips the request state only — no host mutation, no command execution, no dispatch.

target.kind is compliance_remediation_request and target.id is the request id. target.system_id is the host the request applies to.

All four actions emit outcome=success (no denied or failure paths in the current release; validation errors raise before any audit row is written).

Stable context keys present on every action: policy_id, policy_slug, policy_version, check_id, check_slug, check_kind, system_id, evidence_id, evaluation_run_id, verdict_snapshot, severity_snapshot, state, requested_by, decided_by.

ActionWhen it firesAdditional context keys
compliance_remediation.requestedOperator opens a remediation request for a failing evidence rowhas_guidance_snapshot (bool), justification_length (int, 0 when omitted)
compliance_remediation.approvedAdmin approves the request; state flips to approved. Does NOT execute anythingseparation_of_duties_enforced (bool, always true in the current release), decided_reason_length (int)
compliance_remediation.rejectedAdmin rejects the request; terminal statedecided_reason_length (int)
compliance_remediation.cancelledAdmin or original requester withdraws the request; terminal stateself_cancel (bool — true when the requester withdrew their own request), decided_reason_length (int)

Compliance remediation plan previews

Non-executing execution-plan previews derived from approved remediation requests. Building a plan never runs anything on a host, dispatches a job, or mutates the source request. plan_steps is a JSON list of structured operator-readable intent objects (action, target, expected value, safety notes), NOT executable shell.

target.kind is compliance_remediation_plan and target.id is the plan id. target.system_id is the host the plan applies to.

All three actions emit outcome=success. Validation failures (e.g. building against a non-approved request) raise before any audit row is written.

Stable context keys present on every action: request_id, policy_id, check_id, system_id, policy_slug, policy_version, check_slug, check_kind, severity_snapshot.

ActionWhen it firesAdditional context keys
compliance_remediation_plan.builtFirst plan preview is generated for an approved remediation requestplan_state (planned / unsupported / failed), plan_kind, step_count (int), refreshed (bool, always false), superseded_plan_id (int or null)
compliance_remediation_plan.refreshedA non-acknowledged current draft plan is rebuilt in place (idempotent recompute; row id stable)plan_state, plan_kind, step_count, refreshed (bool, always true), superseded_plan_id (always null)
compliance_remediation_plan.unsupportedThe resolved plan kind is unsupported (paired with the matching built/refreshed event)unsupported_reason (string)
compliance_remediation_plan.acknowledgedOperator explicitly acknowledges the current plan (“this is what I intend to run”) — metadata only, no executionplan_kind, ready_for_execution (bool)
compliance_remediation_plan.supersededAn acknowledged current plan is replaced by a fresh build (the new plan becomes current; the old one is locked as history)superseded_by_plan_id (int — the new current plan id)

The plan read envelope also exposes lifecycle metadata fields: check_definition_fingerprint, is_current, superseded_by_plan_id, acknowledged_at, acknowledged_by, is_stale, and ready_for_execution. These are derived/snapshot fields and are not separate audit events. ready_for_execution is the gate a future execution flow will consult; it is true only when the source request is still approved, the plan is current, state='planned', acknowledged, not stale, and of an executable plan_kind (i.e. one of package_install_preview / package_remove_preview / package_upgrade_preview — review-required kinds always read false).

Compliance remediation execution attempts

Durable, pre-dispatch record of an operator’s intent to execute a current acknowledged ready-for-execution package remediation plan. The .created action persists the snapshot + actor + approval lineage and emits one audit event; it does not run commands, dispatch jobs, queue work, mutate hosts, refresh facts, scan packages, reboot, or roll back. The dispatch actions below add transport selection, dispatch, and outcome recording on top of the same row via .dispatched / .succeeded / .failed / .cancelled events.

target.kind is compliance_remediation_execution_attempt and target.id is the attempt id. target.system_id is the host the attempt applies to.

The readiness gate is re-checked at write time so a stale UI cannot bypass it by replaying a previously-true ready_for_execution flag. Validation failures (missing plan, superseded plan, non-planned state, unacknowledged, stale, non-package plan_kind, source request no longer approved) raise before any audit row is written.

ActionWhen it firescontext keys
compliance_remediation_execution.createdOperator creates a durable execution-attempt row for an acknowledged, ready, package remediation plan. State persists as pending; nothing dispatches yet.attempt_id (the attempt id; also surfaced as the target.id), request_id, plan_id, policy_id, check_id, system_id, policy_slug, policy_version, check_slug, check_kind, severity_snapshot, plan_kind_snapshot (one of the three executable package plan kinds), package_name (nullable when the plan was built against a deleted check), package_version_target (set for upgrade plans), state (always pending for this action), approval_decided_by (snapshot of the request’s approver, nullable if the user row is later deleted), dispatched (bool, always false for this action)
compliance_remediation_execution.dispatchedOperator dispatches a pending attempt through the governed patch transport. State flips from pendingdispatched; dispatched_at is set. The audit row commits BEFORE the transport call so a transport hang still leaves a durable record.Same stable keys as .created (without dispatched), plus package_family (apt / dnf) and command_program (the argv[0], i.e. apt-get or dnf)
compliance_remediation_execution.succeededDispatch returned exit 0 with no transport error. State flips from dispatchedsucceeded; completed_at set; exit_code, duration_ms, transport, bounded stdout_summary / stderr_summary written to the attempt row.Same stable keys as .created (without dispatched), plus package_family, exit_code (always 0), duration_ms, transport, failure_reason (always null)
compliance_remediation_execution.failedDispatch returned a non-zero exit code, the transport raised, or the adapter raised. State flips dispatchedfailed; failure_reason carries the short stable code (transport_unavailable, transport_error, package_manager_failed, etc.); error_message carries a bounded operator-readable summary.Same stable keys as .created (without dispatched), plus package_family, exit_code (may be -1 for transport errors), duration_ms, transport, failure_reason
compliance_remediation_execution.batch_dispatchedOperator triggers a bounded request-scoped batch dispatch. The batch loops dispatch_attempt(...) over every pending attempt for the named request (capped by limit, default = MAX_BATCH_SIZE = 500); per-attempt .dispatched / .succeeded / .failed events still fire from the inner path. This event records the aggregate once per batch call, after the loop finishes. target.kind is compliance_remediation_request (not the attempt) because the batch is request-scoped; target.id is the request id.request_id, policy_id, policy_slug, check_slug, check_kind, system_id, limit, total_eligible (≤ limit), dispatched_count (succeeded + failed; excludes refused), succeeded_count, failed_count, refused_count (count of attempts whose pre-flight ComplianceError left the row in pending), failure_breakdown_by_reason (dict keyed on failure_reason for the failed subset)

The attempt read envelope additionally exposes approval_decided_at, transport, failure_reason, error_message, exit_code, duration_ms, stdout_summary, stderr_summary, dispatched_at, and completed_at. These are reserved on pending attempts and populated on the same row as the attempt moves through dispatched → succeeded | failed. Bounded fields: stdout_summary / stderr_summary are truncated to 64 KiB each at the service layer; error_message is truncated to 2048 chars; transport and failure_reason are bounded by the column type.

The readiness gate is re-checked at dispatch time so a stale UI cannot bypass it by replaying a previously-true ready_for_execution flag. Lineage drift (mismatched request_id, plan_kind drift, missing package_name, unsafe package_name, missing/unparsable upgrade version target, unknown host package-manager family, source request no longer approved, plan superseded/stale/unacknowledged/non-planned) raises before any audit row is written and before any host mutation. The dispatch path uses only the governed transport seam (patch_execution_dispatch_service.default_dispatch); there is no raw SSH/agent/subprocess/local-fallback path.

Patch and compliance notifications

This release expands the notification vocabulary alongside the existing audit-event vocabulary. Notifications flow through the existing notification_service.create_notificationalert_service.send_alert path: per-user disable (notification_preferences), per-fleet smart-group scope, and the existing retry/dead-letter behavior all apply unchanged. There are no new audit-event actions here — emission lives beside the existing service-level audit emits at the same stable state transitions, and the helper functions in app.services.notification_events are best-effort (failures log + swallow so an audit event always wins precedence).

Event vocabulary (each is also a valid disabled_types entry on the /notification-preferences route and a valid events[] entry on an AlertConfig):

EventSeverityFires from
patch.executedinfo on succeeded, warning on canceled, error on failedpatch_execution_dispatch_service._maybe_finalize_execution when an execution reaches its terminal state
patch.reboot_requiredwarningpatch_reboot_service.auto_reconcile_on_terminal when a queued reboot row is added for a host that needs a reboot
patch.reboot_completedinfo on healthy, error on failedpatch_reboot_verify_service.verify_due_reboots once per row’s terminal verify transition
patch.rollback_startedwarningpatch_rollback_dispatch_service.start_rollback_execution
patch.rollback_completedinfo on succeeded, warning on canceled, error on failedpatch_rollback_dispatch_service._maybe_finalize_run when the run reaches its terminal state
compliance.evaluatedtracks the dominant per-host verdict (pass → info, fail → warning, error → error)compliance_evaluation_service.evaluate_policy_for_host after the evidence rows commit
remediation.requestedwarningcompliance_remediation_service.create_request after commit
remediation.readyinfocompliance_remediation_plan_service.acknowledge_plan only when the acknowledgement flipped ready_for_execution to true
remediation.executedinfocompliance_remediation_execution_service.dispatch_attempt on succeeded
remediation.failederrorcompliance_remediation_execution_service.dispatch_attempt on failed (carries failure_reason in the message body)

Titles and message bodies are bounded at 160 / 1024 chars respectively. Notifications are broadcast (no user_id target) so they appear in the unread feed for every operator who has the event enabled.

Patch and compliance reporting / exports

Manual operator-driven export endpoints for review-period reporting. Each event emits after the response body is built so the recorded row_count matches the bytes the operator received. target.id is null because the export is a bounded multi-row query, not a single-resource read. outcome is always success: bad windows, bad filter values, and row-cap violations raise HTTP 422 before any audit row is written.

ActionWhen it firestarget.kindcontext keys
compliance_export.requestedOperator downloads compliance evidence over a bounded review window via GET /compliance/exports/evidence.{jsonl,csv}compliance_evidence_exportformat (jsonl / csv), filters (evaluated_after, evaluated_before, policy_id, system_id, verdict), row_count
compliance_remediation_export.requestedOperator downloads compliance remediation requests over a bounded review window via GET /compliance/exports/remediation-requestscompliance_remediation_request_exportformat (csv / json), filters (created_after, created_before, policy_id, system_id, state), row_count
patch_execution_export.requestedOperator downloads patch update executions over a bounded review window via GET /patch/update-executions/exportpatch_execution_exportformat (csv / json), filters (started_after, started_before, plan_id, state), row_count
patch_plan_export.requestedOperator downloads patch update plans over a bounded review window via GET /patch/update-plans/exportpatch_update_plan_exportformat (csv / json), filters (created_after, created_before, policy_id, state), row_count
patch_reboot_export.requestedOperator downloads the per-execution reboot queue via GET /patch/update-executions/{id}/reboots/exportpatch_reboot_queue_exportformat (csv / json), filters (execution_id), row_count
patch_rollback_export.requestedOperator downloads the per-execution rollback dispatch run + per-host rows via GET /patch/update-executions/{id}/rollback/exportpatch_rollback_run_exportformat (csv / json), filters (execution_id), row_count
compliance_remediation_plan_export.requestedOperator downloads compliance remediation plans (current + superseded) over a bounded review window via GET /compliance/exports/remediation-planscompliance_remediation_plan_exportformat (csv / json), filters (created_after, created_before, policy_id, system_id, state, current_only), row_count
compliance_remediation_execution_export.requestedOperator downloads compliance remediation execution attempts over a bounded review window via GET /compliance/exports/remediation-executionscompliance_remediation_execution_exportformat (csv / json), filters (created_after, created_before, policy_id, system_id, state), row_count

Scheduled report runs do NOT add a new audit-event action — the scheduler tick (apscheduler report_schedules_due job, every 5 minutes) invokes the same per-kind dispatcher used by the manual export routes, so the same *_export.requested audit events fire from the scheduled-firing path. The only persisted difference is the matching report_runs row, which is written with triggered_by='system_scheduled' (vs 'user' for manual exports). Operators distinguish manual vs scheduled runs by reading the triggered_by column on GET /reports/runs. Schedule definitions live in the report_schedules table (admin/maintainer write, auditor read via GET /reports/schedules) with plain-language cadence (daily / weekly / monthly) — no cron expression ever reaches the wire per feedback_no_cron.md.

Bounded review-window guard: created_after/created_before (and started_after/started_before) default to the last 30 days when both bounds are omitted and a single request cannot span more than EXPORT_WINDOW_MAX_DAYS = 366 days. The remediation-request and patch-execution exports additionally cap a single response at EXPORT_MAX_ROWS = 50_000; oversized filters raise HTTP 422 with operator-readable text rather than truncate. The compliance evidence export is streamed (yield_per) and therefore not row-capped.

RBAC: all three export endpoints require admin or maintainer. Auditors can read individual records through the per-resource detail routes but cannot trigger a bulk export.


Delivery guarantees

Events persist to the database synchronously with the action that emitted them (same DB transaction where reasonable). External sinks receive events at least once via the delivery queue:

  • Pending delivery rows are drained every 30 seconds.
  • Transport failures retry with exponential backoff: 5s → 15s → 1m → 5m → 15m → 1h.
  • After 6 attempts, deliveries move to dead_letter and stop retrying automatically. Admin can re-queue via POST /audit/deliveries/{id}/retry.
  • Sink receivers should deduplicate on event_uuid.

Transport formats

HTTP

POST {target}
Content-Type: application/json
X-Praxis-Signature: sha256={hex} # when hmac_secret is configured
User-Agent: Praxis-Audit/1.0
{ event JSON }

Signature = HMAC-SHA256 of the request body bytes keyed with the sink’s secret.

Syslog (RFC 5424)

TCP (TLS by default, disable via config.tls=false). Octet-counting framing per RFC 6587. MSG part is the event JSON. Facility/severity configurable (defaults: user / informational).

File

JSONL append at the configured path. One event per line. Receiver is expected to rotate.

Versioning policy

We bump schema_version only for breaking changes (field rename, field removal, semantic change to an existing field). Additive changes (new actions, new context keys) do NOT bump version — consumers must tolerate unknown fields.