Audit log
Every state-changing surface in Covenant emits an AuditEvent to the append-only log at $COVENANT_HOME/audit/events.jsonl. The log is the ground truth: operators read it directly, covenant verify cross-checks it against the other state files, covenant audit verify checks the local hash-chain sidecar, and the covenant audit recent route reads from the same file.
Event envelope
AuditEvent {
id: uuid, // unique per event
timestamp_ms: u64, // unix milliseconds
issuer: AgentId, // the daemon's local identity
kind: AuditKind // tagged variant — see below
}Variants
IntentDispatched
{
"type": "intent_dispatched",
"intent_id": "uuid",
"intent_text": "…",
"matched_agent": "research" | null,
"result_hash_hex": "…",
"status": "ok"
}IntentIgnored
{
"type": "intent_ignored",
"intent_id": "uuid",
"intent_text": "…",
"matched_pattern": "**/*.pem"
}CapabilityCheck
{
"type": "capability_check",
"agent_id": "research" | "tool:echo",
"required_actions": ["tool.web_search"],
"missing_actions": [],
"passed": true
}CapabilityGranted
{
"type": "capability_granted",
"subject_display": "user@local",
"action": "tool.web_search",
"granted_by_display": "user@local",
"signature_b58": "4qXP…8tF1"
}CapabilityGrantRejected
{
"type": "capability_grant_rejected",
"subject_display": "user@local",
"action": "memory.write",
"reason": "scope schema: missing tier"
}Emitted when the daemon refuses a CreateCapability request at scope validation. For example, a memory.writegrant whose scope object does not match the action's schema. Distinct from CapabilityRevokeRejected (rejection on the revoke path, keyed on signature) and from CapabilityCheck (a dispatch-time check against an already-issued capability): no capability is issued here, and reason carries the validator message the caller saw. The requesting peer is the issuer of the row.
CapabilityScopeRejected
{
"type": "capability_scope_rejected",
"agent_id": "memory:write",
"action": "memory.write",
"reason": "tier 'long_term' outside scope"
}Emitted at dispatch time when the caller holds a capability for action but the request falls outside its scope. For example, an audit.purge call whose before_ms predates the scope window, or a memory.write at a tier the grant did not authorise. agent_id is a synthetic id naming the runtime surface that performed the check (memory:write, tool:web_search, audit:purge); action is the capability action string; reason is the scope-mismatch message returned to the caller. Distinct from CapabilityCheck with passed: false, which fires when a required action is missing entirely from the issuer's capability set; scope rejection assumes the action is present and the rejection is on its bound scope.
CapabilityRevokeRejected
{
"type": "capability_revoke_rejected",
"signature_b58": "4qXP…8tF1",
"reason": "issuer mismatch"
}Emitted when the daemon refuses a capability revocation request. For example, an issuer that does not own the signature or a payload whose signature does not verify. Successful revocations are not audit events; they write tombstone rows to the capability store instead. The audit log only records the rejected attempt so unauthorized revocation requests stay visible to operators.
AuthenticationFailed
{
"type": "authentication_failed",
"transport": "http",
"reason": "missing Authorization header"
}Emitted on every rejected authentication attempt: a bad first frame on the IPC socket, a missing or malformed Authorization header on HTTP, or a token the registry does not resolve. transport is "ipc" or "http"; reasonis the same short message the caller saw. Because the caller failed to authenticate, no peer identity is bound to the row: the issuer is the daemon's ownAgentId, which keeps probe-attribution events visible on the operator's /audit feed independent of the cross-peer audit-feed isolation filter. Distinct from OperatorTokenRotationRejected and OperatorPeerRevokeRejected, which fire after a peer authenticates and then fails an authorization gate.
PeerRevoked
{
"type": "peer_revoked",
"peer_display": "guest@local",
"peer_pubkey_b58": "9hYz…Lk6w",
"token_prefix": "4qXP6t"
}Emitted when the operator successfully revokes a peer registry entry via RevokePeer. The issuer is the operator (peer-event audience: the recording path asserts that the issuer's pubkey matches the acting peer's); peer_display and peer_pubkey_b58 describe the revoked peer, not the operator; peer_pubkey_b58 is the unforgeable identifier (display is wire-supplied). token_prefix is the same 6-char base58 redaction that OperatorTokenRotated records. Full token bytes never enter the audit log, and the prefix lets an operator confirm a revoked entry matches the durable on-disk peer registry. Distinct from OperatorPeerRevokeRejected (daemon-issuer probe by a non-operator) and PeerSelfRevokeBlocked (operator-issuer fat-finger on their own bootstrap token).
PeerSelfRevokeBlocked
{
"type": "peer_self_revoke_blocked",
"peer_display": "user@local",
"peer_pubkey_b58": "9hYz…Lk6w",
"token_prefix": "4qXP6t"
}Emitted when the operator's RevokePeer request would have revoked their own bootstrap token but force was false. The daemon returns RevokeOutcome::SelfRevokeForbidden and the registry is unchanged. The operator IS both the issuer and the audience here, so the row surfaces in their own /audit feed for self-fat-finger triage, deliberately distinct from OperatorPeerRevokeRejected, which records a non-operator's probe under the daemon-as-issuer audience so the operator can see the probe on their feed without exposing it to the rejected peer. peer_pubkey_b58 is the unforgeable operator identity (the display is wire-supplied) and token_prefix is the same 6-char base58 redaction PeerRevoked and OperatorTokenRotated record.
OperatorTokenRotated
{
"type": "operator_token_rotated",
"peer_display": "user@local",
"old_token_prefix": "4qXP6t",
"new_token_prefix": "8wPN3z"
}Emitted when the operator rotates their bootstrap token via RotateOperatorToken. The issuer is the operator (peer-event audience: the recording path asserts the issuer's pubkey matches the acting peer's). Full token bytes never enter the audit log: both prefixes are the same 6-char base58 redaction that PeerRevoked records and that PeerToken::Debugformats, so an operator can correlate a rotation row with the on-disk token file's first chars without ever exposing the secret. new_token_prefix is load-bearing: it lets an operator confirm, after any rotation they did or did not initiate, that the file on disk came from this row rather than a silent compromise. Distinct from OperatorTokenRotationRejected, the daemon-issuer probe row recorded when a non-operator peer authenticates but fails the identity-pubkey equality gate.
OperatorTokenRotationRejected
{
"type": "operator_token_rotation_rejected",
"peer_display": "guest@local",
"peer_pubkey_b58": "9hYz…Lk6w"
}Emitted when RotateOperatorTokenis rejected because the authenticated peer's pubkey does not match the operator identity. The gate is silent in v0 single-peer (only the operator can authenticate, so the rejection branch is dead code) and becomes load-bearing at Phase-1 multi-peer where a guest peer reaching this path is a probe worth surfacing on the operator's /audit feed. The issuer is the daemon identity (not the rejected peer) so the row passes the cross-peer audit-feed isolation filter, mirroring AuthenticationFailed's audience model. peer_pubkey_b58 is the unforgeable identity; display is wire-supplied and a future attacker could register user@local against any pubkey, so the base58 form (matching bs58::encode(peer.pubkey)) is the durable probe attribution. Distinct from AuthenticationFailed (the peer authenticated successfully; they failed an authorization check, not authentication).
OperatorPeersListRejected
{
"type": "operator_peers_list_rejected",
"peer_display": "guest@local",
"peer_pubkey_b58": "9hYz…Lk6w"
}Emitted when ListPeersis rejected because the authenticated peer's pubkey does not match the operator identity (the gate is peer.pubkey != self.identity.pubkey). The issuer is the daemon identity (not the rejected peer), mirroring OperatorTokenRotationRejected's daemon-as-issuer audience model: the row surfaces on the operator's own /auditfeed for probe triage, and recording it against the rejected peer would (a) hide the probe from the operator under the cross-peer audit-feed isolation filter and (b) turn the rejected peer's own feed into a probe-was-logged oracle. peer_pubkey_b58 is the unforgeable identity; peer_display is wire-supplied and a future attacker could register user@local against any pubkey, so the base58 form (matching bs58::encode(peer.pubkey)) is the durable probe attribution that survives operator grep through the audit log unmodified. Distinct from CapabilityCheck (no capability is checked: the gate is identity-pubkey equality) and from AuthenticationFailed (the peer authenticated successfully; they failed an authorization check, not authentication).
OperatorPeerRevokeRejected
{
"type": "operator_peer_revoke_rejected",
"peer_display": "guest@local",
"peer_pubkey_b58": "9hYz…Lk6w"
}Emitted when RevokePeeris rejected because the authenticated peer's pubkey does not match the operator identity. Daemon-as-issuer audience model matching OperatorTokenRotationRejected and OperatorPeersListRejected: recording the rejection under the rejected peer would (a) hide the probe from the operator's /auditfeed under the cross-peer audit-feed isolation filter and (b) turn the rejected peer's own feed into a probe-was-logged oracle. peer_pubkey_b58 is the unforgeable identifier; peer_display is wire-supplied and a future attacker could register user@local against any pubkey, so the base58 form (matching bs58::encode(peer.pubkey)) is the durable probe attribution. Distinct from PeerRevoked (operator-issuer success row that carries token_prefix for the revoked entry) and from PeerSelfRevokeBlocked (operator-issuer fat-finger guard where the operator is both issuer and audience). Distinct from CapabilityCheck (no capability is checked: the gate is identity-pubkey equality) and from AuthenticationFailed (the peer authenticated successfully; they failed an authorization check, not authentication).
BudgetExhausted
{
"type": "budget_exhausted",
"agent_display": "research@local",
"intent_id": "uuid",
"intent_text": "…",
"requested": 1024,
"tokens_remaining": 256,
"refill_eta_ms": 600000
}Emitted by the daemon when the matched agent's token-bucket ledger refuses a debit. The same row doubles as the resume queue: covenant intents resume <intent-id> re-dispatches from this event, so the six fields above are load-bearing.
BudgetPreempted
{
"type": "budget_preempted",
"agent_display": "research@local",
"intent_id": "uuid",
"reason": "projected cpu overshoot at 28.4s/30s",
"signal_sent": "SIGTERM",
"exit_code": null
}Emitted when the budget-hard-preempt path successfully terminates a still-running over-budget subprocess. Distinct from BudgetExhausted (a post-completion debit rejection on a finished run): preempt actively kills a running process. signal_sent classifies the path the daemon took: "SIGTERM" for the cooperative-grace attempt, "SIGKILL" when the subprocess outlived the grace window, and "none" when the subprocess exited naturally inside the grace window before any signal was needed. exit_code is null for signal-terminated processes (the daemon observed termination via child.wait() only); the field surfaces as JSON null on the wire so the schema stays stable across cooperative-exit and forced-kill rows. The five fields are load-bearing for post-mortems that classify cooperative vs. forced terminations.
BudgetPreemptFailed
{
"type": "budget_preempt_failed",
"agent_display": "research@local",
"intent_id": "uuid",
"reason": "kill(SIGTERM) returned ESRCH",
"errno": 3
}Emitted when the budget-hard-preempt path attempted to signal the subprocess but the signal-send syscall returned an error. The errno field is operationally load-bearing for triage: 3 (ESRCH) is benign, since the subprocess exited before the daemon could signal it, which the daemon may also surface as a BudgetPreempted row with signal_sent: "none" on a different code path. 1 (EPERM) is a security incident: the daemon lacks signal-send permission for that pid, and an operator should investigate before the bucket fills again. The four fields are load-bearing for incident triage and for any future tooling that classifies the cooperative-race-vs-privilege-regression axis.
BudgetUnseeded
{
"type": "budget_unseeded",
"agent_display": "research@local",
"intent_id": "uuid",
"requested": 1024
}Emitted when dispatch_intent falls into the NoCapacity fail-open arm: the manifest opted in to budget enforcement (budget_credits_per_hour > 0) but no bucket was seeded for the agent: the operator forgot to call register_agent_budgets, or a hot-reload added the manifest without re-seeding. v0 logs the row and passes the dispatch. The variant is deliberately distinct from BudgetExhausted so /audit consumers can filter operator-misconfig from policy-rejection without special-casing sentinel values: a collapsed schema would force consumers to disambiguate by inspectingtokens_remaining, which only exists on the exhausted row.
MemoryRepairApplied
{
"type": "memory_repair_applied",
"memory_id": "uuid",
"action": "detach_parent" | "delete_record" | "backfill_provenance",
"mode": "apply" | "dry_run",
"changed": true,
"reason": "operator-corrected receipt"
}Emitted when covenantd::Server::repair_memory completes a scoped repair against a single memory record. The full before/after record shape is returned to the caller through the repair response; the audit row keeps the durable who/what/why envelope without duplicating memory text into the audit log. The issuer is the requesting peer (operator-as-issuer audience: the recording path asserts the issuer's pubkey matches the acting peer's), recorded only after the dispatch-time capability check and the memory.repair.<mode> scope check both pass; if either fails the daemon emits a CapabilityCheck or CapabilityScopeRejected row instead, so a MemoryRepairApplied row never coexists with a rejection row for the same request. action and mode are parser-fixed snake_case tokens produced by memory_repair_action and memory_repair_mode in covenantd. The three valid action values are "detach_parent", "delete_record", and "backfill_provenance"; the two valid mode values are "apply" and "dry_run". changed is true only when mode is "apply" and the planner reported a would-change outcome; a dry-run row always reports changed: false, and an apply that found the record already in the requested state also reports changed: false. The flag is the mutation-vs-no-op triage signal that distinguishes a repair that actually edited from one that found nothing to change; a refactor that #[serde(default)]-ed it would mask that signal. Distinct from MemoryCompactionApplied (bulk path with deleted, stale_marked, and parents_detached id lists; one row per compaction run rather than per record).
MemoryCompactionApplied
{
"type": "memory_compaction_applied",
"mode": "apply" | "dry_run",
"changed": true,
"reason": "operator-bounded compaction",
"deleted": ["uuid", "…"],
"stale_marked": ["uuid", "…"],
"parents_detached": ["uuid", "…"]
}Emitted when covenantd::Server::compact_memory completes a bounded compaction run. The issuer is the requesting peer (operator-as-issuer audience), recorded only after the memory.compact.<mode> capability gate and a follow-up operator-identity equality check both pass. Even a guest peer that somehow holds memory.compact.apply is rejected with an operator-identity error before any compaction runs, so a non-operator issuer on this row should be read as a regression. Capability-scope failures emit CapabilityScopeRejected instead, so a MemoryCompactionApplied row never coexists with a rejection row for the same request. mode shares the parser-fixed snake_case vocabulary produced by memory_repair_mode in covenantd: "apply" or "dry_run". deleted, stale_marked, and parents_detached carry the id arrays that classify what the run touched: ids only, never memory text, so the audit stream stays redactable while operators retain enough to correlate against memory plan-compaction dry-run output. changed is the compaction-mutation-vs-no-op triage signal; a refactor that #[serde(default)]-ed any of the three id lists would let a malformed row decode with empty lists and erase which ids were touched (the test pin in covenant-audit documents this exact regression). Distinct from MemoryRepairApplied (single-record path keyed on a memory_id and one of three action tokens; bulk compaction reports id arrays instead, one row per run rather than per record).
A2ARepairApplied
{
"type": "a2_a_repair_applied",
"task_id": "uuid",
"action": "requeue" | "force_error" | "auto_requeue",
"reason": "operator-cleared stuck lease",
"lease_id": "uuid",
"duplicate_risk": "idempotent" | "low" | "high" | null,
"attempt": 1
}Emitted when covenantd::Server repairs an in-flight A2A mailbox lease, either via an operator RepairA2A request after the a2a.repair scope check passes, or by the disabled-by-default A2A auto-retry scheduler running as the operator peer. Full task payloads stay in the mailbox log; the audit row records who acted, why, and which lease they intended to mutate. The issuer is the requesting peer (operator-as-issuer audience). action is "requeue" or "force_error" for operator-issued repairs and "auto_requeue"for scheduler-issued ones; the slug is the only triage signal that separates a scheduler-driven retry from an operator-driven one on the operator's /audit feed. attemptis the lease's retry-count after the repair lands, which distinguishes a first repair (1) from a re-repair on the same lease.
The wire-form type discriminator is "a2_a_repair_applied", not a2a_repair_applied: serde's rename_all = "snake_case" splits the A2Aprefix on each digit/uppercase boundary so the durable on-disk slug carries the extra underscore. A refactor that "fixed" the slug to a2a_repair_applied would silently strand every prior A2A-lease-repair audit row at decode time, which is why the wire shape is pinned in covenant-audit. lease_id and duplicate_risk are Option fields but carry no #[serde(skip_serializing_if)], so both keys always surface on the wire, set to nullwhen the daemon did not bind them. The wire shape stays at exactly seven keys across every repair row regardless of whether the daemon classified the lease's duplicate-risk; a doc example that elided either key for the None case would let a future skip_serializing_if regression masquerade as the documented contract. Distinct from MemoryRepairApplied (different subject: an A2A mailbox lease, not a memory record; the audit-feed audience and scope vocabularies are operator-only on both, but cross-feed joins should key on this slug, not on the peer-event audience).
A2AAutoRetrySchedulerScan
{
"type": "a2_a_auto_retry_scheduler_scan",
"enabled": true,
"considered": 10,
"requeued": 2,
"skipped": 8,
"skipped_by_reason": { "max_attempts": 5, "lease_age": 3 },
"min_lease_age_ms": 30000,
"max_attempts": 3,
"max_requeues": 100,
"scan_limit": 200,
"error": null
}Emitted by the disabled-by-default A2A retry scheduler after each scan run. Requeued tasks still surface as individual A2ARepairApplied rows with action: "auto_requeue"; this summary row makes skipped and rejected scans visible without duplicating per-task payloads. The issuer is the daemon's identity (the recording path uses record_daemon_event, not record_peer_event), so the audience model mirrors AuthenticationFailed rather than A2ARepairApplied: even though both A2A rows share the a2_a_ slug stem, joins that key on the peer-event audience will miss every scan row. The policy snapshot fields (enabled, min_lease_age_ms, max_attempts, max_requeues, scan_limit) record the configuration the scan ran under, so an operator can correlate a sudden requeued-count change with a policy edit without rereading the daemon log. skipped_by_reason is a JSON object keyed by skip-reason with per-reason counts; it makes the operator-misconfig-vs-policy-gate breakdown legible without landing the full task payloads on the audit stream. The wire slug is "a2_a_auto_retry_scheduler_scan", not a2a_auto_retry_scheduler_scan: the same serde rename_all = "snake_case" digit/upper split that A2ARepairApplied carries, pinned in covenant-audit. error is Option<String> without #[serde(skip_serializing_if)], so the key surfaces as JSON null on success scans and the eleven-key wire shape stays stable across success and failure rows. Distinct from A2ARepairApplied (one summary row per scan vs one row per repaired lease; joining the two surfaces by task_id reconstructs which leases the scan touched).
A2ARecipientRejected
{
"type": "a2_a_recipient_rejected",
"sender_display": "attacker@local",
"recipient_display": "victim@local",
"action": "a2a.recv.attacker@local"
}Emitted when SendA2ATask is rejected because the recipient peer has not granted a2a.recv.<sender> to themselves. The gate closes the recipient-inbox spam vector that would otherwise be exploitable when a peer with a granted send-cap pushes tasks at a recipient that has not granted matching recv-caps: without it, a malicious peer could route arbitrary intent_text into the recipient's RecentA2ATasks view via the bidirectional filter. The issuer is the sender peer (their failed attempt), but the missing cap belongs to a different subject, the recipient, which is the entire reason the variant exists as its own kind rather than as a CapabilityCheck row. Both sender_display and recipient_display are load-bearing for the two-party diagnostic; action is the formatted scope name (a2a.recv.<sender_display>) that the recipient never granted, which lets an operator triaging the row pivot directly to the recipient's grant decisions without recomputing the missing-cap string. The wire-form type slug is "a2_a_recipient_rejected", not a2a_recipient_rejected: the same serde rename_all = "snake_case" A2A digit/upper split pinned in covenant-audit. Distinct from CapabilityCheck with passed: false(which would misattribute the missing cap to the issuer's capability set rather than the recipient's) and from A2ASenderMismatch (sender-identity mismatch on the sender's own caps, not a recipient-side cap gap).
A2ASenderMismatch
{
"type": "a2_a_sender_mismatch",
"peer_display": "attacker@local",
"claimed_sender_display": "victim@local"
}Emitted when SendA2ATask is rejected because the supplied task.sender does not match the authenticated peer on the connection. The gate closes the sender-spoof attack class: a malicious local process claiming to be a different agent on the wire than the one bound to its peer token. The check fires as a precondition before any cap check runs, so a spoof attempt never even reaches A2ARecipientRejected or the CapabilityCheck path on the same SendA2ATask. The issuer is the actual authenticated peer (the recording path uses peer.clone() against record_peer_event_required), not the spoofed identity, so an attacker can't hide the attempt behind the impersonated peer's audit feed. peer_display and claimed_sender_display are both load-bearing for the two-identity diagnostic; a #[serde(default)] on either would collapse the spoof event into a one-sided diagnostic and lose the attribution. The wire-form type slug is "a2_a_sender_mismatch", not a2a_sender_mismatch: the same serde rename_all = "snake_case" A2A digit/upper split. Distinct from A2ARecipientRejected (post-spoof-gate path: a recipient-side cap gap on an honestly-attributed sender) and from AuthenticationFailed (no peer authenticated; no task.sender claim to compare against).
A2AResultRejected
{
"type": "a2_a_result_rejected",
"task_id": "uuid",
"reason": "unknown_task"
}Emitted when PostA2AResult is rejected upstream of any capability check, currently when mailbox.lookup_task_sender(task_id) returns no sender, meaning the supplied task_id was never dispatched through this daemon. The reason field carries the literal "unknown_task" for that path. Fires before the a2a.respond cap check would even run, so an A2AResultRejected row never coexists with a CapabilityCheck for the same PostA2AResult request. The issuer is the authenticated peer that submitted the bogus task_id (peer-as-issuer audience via record_peer_event): the peer's own audit feed surfaces the row so a benign client bug stays visible to its operator without leaking the rejection to other peers. The variant is a stronger compromise indicator than a missing-cap rejection: an honest agent's a2a.respond would carry a task_id that was actually dispatched through this daemon, so a missing-task row implies the agent fabricated the id or replayed one from a different daemon. The wire-form type slug is "a2_a_result_rejected", not a2a_result_rejected: the same serde rename_all = "snake_case" A2A digit/upper split. Distinct from CapabilityCheck with passed: false (which assumes the task_id is valid and only the cap is short; a refactor that moved the unknown-task gate behind the cap check would silently absorb this stronger signal into a routine cap miss) and from A2ASenderMismatch (sender-spoof at the send path rather than result-injection at the respond path).
HermesToolInvoked
{
"type": "hermes_tool_invoked",
"intent_id": "uuid",
"run_id": "run_abc",
"tool": "terminal",
"preview_hash_hex": "8f1c…0c2e"
}Emitted by covenantd's runtime-trace fold (runtime_trace_to_audit_kind) when a Hermes-runtime agent starts a tool invocation inside its run loop. intent_id stamps the parent intent so the audit row ties back to the broader intent context; a refactor that dropped the stamping would strand every Hermes tool invocation from the originating IntentDispatched row. run_id is the Hermes-side run identifier and is the grouping key that pairs this row with the matching HermesToolCompleted row (joining on intent_id + run_id + tool reconstructs the tool-call duration). preview_hash_hexis the SHA-256 of Hermes's short tool-input preview: the raw preview text is hashed via covenant_audit::hash_hex before persisting, so the audit chain never embeds raw tool input. That redaction floor is load-bearing: a refactor that "simplified" by passing the raw preview through (e.g., under a "preview already operator-facing" rationale) would silently leak every Hermes tool-input preview verbatim into the persisted audit chain, which is why the wire form pins preview_hash_hex rather than any unhashed alternative. Distinct from IntentDispatched (one row per intent; Hermes runs emit many HermesToolInvoked rows per intent, one per tool call) and from HermesToolCompleted (end-of-tool row carrying duration_ms and error; pair them by run_id + tool + intent_id).
HermesToolCompleted
{
"type": "hermes_tool_completed",
"intent_id": "uuid",
"run_id": "run_abc",
"tool": "terminal",
"duration_ms": 1234,
"error": false
}Emitted when a tool invocation in a Hermes run finishes. The end-of-tool counterpart to HermesToolInvoked; pair them by intent_id + run_id + tool to reconstruct tool-call latency. duration_ms is the latency the audit row carries verbatim from the runtime trace; operators key Hermes latency dashboards on this field, so a refactor that coerced to a different width or unit would silently shift every dashboard built against milliseconds. error is true iff the tool itself raised; a false here followed by a failed overall run status means Hermes failed elsewhere in the loop (model error, policy denial, transport issue), so this flag is the only reliable per-tool failure signal on the audit feed. A default-to-false regression would mask every tool failure as success; the invariant is pinned in covenant-audit. intent_id stamps the parent intent just like HermesToolInvoked, so tool durations remain attributable to the originating IntentDispatched even after the run terminates. Distinct from HermesToolInvoked (start-of-tool with preview_hash_hex vs end-of-tool with duration_ms and error) and from HermesApprovalRequested (a run pausing for operator approval is not a tool completion; approval rows do not carry tool or duration_ms).
HermesApprovalRequested
{
"type": "hermes_approval_requested",
"intent_id": "uuid",
"run_id": "run_abc",
"choices": ["allow", "deny"]
}Emitted when a Hermes run pauses pending operator approval, recorded so a run stalled at the approval prompt stays auditable even when the operator console is closed. intent_id stamps the parent intent so the audit feed surfaces a stuck approval against the originating IntentDispatched. run_id is the same Hermes-side run identifier the tool rows carry and pairs this request half with the matching HermesApprovalResolvedrow that records the operator's answer. choicesis the ordered list Hermes presents to the operator; the runtime-trace fold passes the vector through verbatim in order because operator approval UIs render the list in audit-supplied order, so a sort or dedup pass at this layer would silently reorder every operator's approval prompt across deployments (the invariant is pinned in covenant-audit). Distinct from HermesToolInvoked and HermesToolCompleted (the approval pause is not a tool, so there is no tool or preview_hash_hex or duration_ms) and from HermesApprovalResolved (start-of-pause vs end-of-pause; the pair shares intent_id + run_id, but the resolved row adds the chosen choice and the resolved count Hermes reports as cleared by the response).
HermesApprovalResolved
{
"type": "hermes_approval_resolved",
"intent_id": "uuid",
"run_id": "run_abc",
"choice": "allow",
"resolved": 1
}Emitted when an operator (or auto-policy) answers a pending Hermes approval: the end-of-pause counterpart to HermesApprovalRequested, paired by intent_id + run_id so operators reconstruct approval latency by joining the two rows. intent_id stamps the parent intent so the resolution stays attributable to the originating IntentDispatched even after the run terminates. run_id is the same Hermes-side run identifier the request row carries. choice is the selected string from the originally presented choices vector; the invariant is that the value matches one of the entries the request row recorded, so a free-form unrelated string here would silently break approval-lifecycle reconstruction across deployments. resolvedis the count of pending requests Hermes reports as cleared by this response, recorded as the per-response cleared count (not a running total). An accumulator regression that summed across rows would make every operator's approval dashboard count duplicates, which is why the wire form pins the per-response semantic rather than any running-sum alternative. Distinct from HermesApprovalRequested (start-of-pause carrying the choices vector vs end-of-pause carrying the single selected choice and the resolved count) and from HermesToolCompleted (an approval response is not a tool completion, so there is no tool, duration_ms, or error field).
HermesFileWritten
{
"type": "hermes_file_written",
"intent_id": "uuid",
"run_id": "run_abc",
"path": "src/main.rs",
"bytes": 4096
}Emitted when the Hermes runner observes an SSE file.written event during a run and the daemon folds the resulting RuntimeTrace::HermesFileWritten into the audit chain: the workspace-mutation counterpart to the tool- and approval-lifecycle Hermes rows. intent_id stamps the parent intent so a file write stays attributable to the originating IntentDispatched even after the run terminates; the trace itself carries only run_id, path, and bytes, and the daemon supplies intent_id from the dispatch context as it does for the other Hermes rows, so the wire form is always five keys. run_id is the same Hermes-side run identifier the tool and approval rows carry, joining every write to its run. path is the sandbox-relative path of the written file and passes through verbatim: operator file-tree views key on it, so a redaction would break the join from the audit row to the rendered file path. bytes is the file size as a u64 (the width is load-bearing, since a narrowing to u32 would silently truncate any write above 4 GiB), and it serializes as a JSON number, not a string, so size-based client logic does not break on a quoted value that never appears on the wire. Distinct from HermesToolInvoked and HermesToolCompleted (a file write is not a tool call, so there is no tool, duration_ms, or error field).
SettlementReceiptBackfillApplied
{
"type": "settlement_receipt_backfill_applied",
"row_count": 3,
"rollback_path": "/home/op/receipts/working.jsonl.bak",
"dry_run": false
}Emitted when covenantd::Server::backfill_settlement_receipts completes the authorized settlement receipt backfill mutation. The issuer is the requesting peer (operator-as-issuer audience), recorded only after the dispatch-time settlement.backfill.<mode> capability check, a follow-up operator-identity equality check, and the settlement-backfill capability scope check all pass; capability-scope failures emit CapabilityScopeRejected instead, so a SettlementReceiptBackfillApplied row never coexists with a rejection row for the same request. The row is emitted only after backfill_receipts returned Ok (i.e. after the rollback checkpoint, the rewritten store contents, and the renamed store file are fsynced), so the audit log cannot claim a mutation whose data did not durably land. row_count is the count of legacy rows the backfill plan would change on a dry run and the count it actually rewrote on an apply; an apply that found nothing to change reports row_count: 0 and the mutator short-circuits without writing a rollback file. dry_run is the plan-vs-mutation triage signal: a dry-run row always carries dry_run: true and rollback_path: null, and a refactor that #[serde(default)]-ed either field would let an applied rewrite masquerade as a dry run or mask the applied-vs-planned distinction. rollback_path is the absolute path of the pre-rewrite checkpoint the mutator wrote alongside the receipts store on an apply, null on a dry run or a no-op apply that changed nothing; the field carries no #[serde(skip_serializing_if)] so the wire form is always four keys (the key stays present as null when None): a consumer that filters on the applied-vs-dry split reads dry_run while one that wants the rollback checkpoint reads rollback_path, so both must stay on the wire across the Some and None cases. The row is best-effort like every other completed-mutation kind: the rewrite is already durable and the rollback file is on disk, so audit-write success is not a precondition for the response (the variant is intentionally absent from audit_kind_requires_persistence, which is reserved for suppressible rejection probes whose suppression would hide an attacker probe). Distinct from MemoryRepairApplied and MemoryCompactionApplied (different subject: the settlement receipt store rewrite vs a memory-record mutation; the rollback evidence here is an on-disk checkpoint sibling of the store rather than a SQLite savepoint or per-record before/after payload, and the action vocabulary collapses to the single backfill operation rather than the three repair actions or the three id-array categories compaction reports).
MemoryRecordBackfillApplied
{
"type": "memory_record_backfill_applied",
"row_count": 3,
"savepoint_name": "backfill_receipt_correlation",
"dry_run": false
}Emitted when covenantd::Server::backfill_memory_record_correlations completes the authorized memory-record receipt-correlation backfill mutation. The issuer is the requesting peer (operator-as-issuer audience), recorded only after the dispatch-time memory.backfill.<mode> capability check, a follow-up operator-identity equality check, and the memory-backfill capability scope check all pass; capability-scope failures emit CapabilityScopeRejected instead, so a MemoryRecordBackfillApplied row never coexists with a rejection row for the same request. The row is emitted only after SqliteStore::backfill_receipt_correlation returned Ok (i.e. after the BEGIN IMMEDIATE + SAVEPOINT backfill_receipt_correlation + per-row UPDATE + RELEASE SAVEPOINT + COMMIT all succeed), so the audit log cannot claim a mutation whose data did not durably land. row_count is the count of memory records the planner would correlate on a dry run and the count actually rewritten on an apply; an apply that found nothing to change (all correlations matched the existing metadata.receipt_id) reports row_count: 0 and the mutator short-circuits without leaving any visible savepoint behind. dry_run is the plan-vs-mutation triage signal: a dry-run row always carries dry_run: true and savepoint_name: null, and a refactor that #[serde(default)]-ed either field would let an applied rewrite masquerade as a dry run or mask the applied-vs-planned distinction. savepoint_name is the SQLite SAVEPOINT identifier the mutator wrapped the apply batch in (the constant backfill_receipt_correlation exported as covenant_memory::MEMORY_BACKFILL_SAVEPOINT_NAME), null on a dry run or a no-op apply that changed nothing; the field carries no #[serde(skip_serializing_if)] so the wire form is always four keys (the key stays present as null when None): a consumer that filters on the applied-vs-dry split reads dry_run while one that wants the SAVEPOINT identifier reads savepoint_name, so both must stay on the wire across the Some and None cases, and the stable column set is what lets operator dashboards JOIN this row with SettlementReceiptBackfillApplied under the same backfill-family schema. The row is best-effort like every other completed-mutation kind: the SAVEPOINT-wrapped batch already COMMITted, so audit-write success is not a precondition for the response (the variant is intentionally absent from audit_kind_requires_persistence, which is reserved for suppressible rejection probes whose suppression would hide an attacker probe). Distinct from SettlementReceiptBackfillApplied (the two-sided counterpart writes memory_record_id onto the receipts JSONL store under an on-disk rollback checkpoint rather than metadata.receipt_id onto memory records under a SQLite SAVEPOINT: same correlation operation, opposite direction and different rollback mechanism), and from MemoryRepairApplied and MemoryCompactionApplied (different action vocabulary: the receipt-correlation backfill writes the receipt_id field into metadata rather than detaching parents, deleting records, backfilling provenance, or marking long-term entries stale, and the rollback evidence here is a SAVEPOINT identifier rather than per-record before/after payloads or id-array categories).
Properties
- Append-only during normal writes. The file is opened for append on event record. Operator-driven retention purge rewrites the retained rows and the sidecar together.
- Locally chained. The daemon writes
$COVENANT_HOME/audit/events.chain.jsonlwith a SHA-256 hash chain over retained event rows. - One event per line. Compatible with
tail -F,jq, and other JSONL-aware tooling. - Deterministic schema. Each variant serialises with a stable
kindtag plus its payload. Adding new variants is a backward-compatible schema change. - Cross-checked.
covenant verifyruns four audits over a rolling window:- memory ↔ audit: every memory record has a matching
IntentDispatched. - memory parent references: every parent id resolves in the memory store.
- capability ↔ audit: every granted capability has a matching
CapabilityGranted. - memory ↔ receipts: memory writes and settlement receipts pair by
memory_record_id, with legacy count fallback.
- memory ↔ audit: every memory record has a matching
Reading the log
Last few events
covenant audit recent --limit 5 --json
# Or via HTTP:
curl -s '127.0.0.1:8421/audit/recent?limit=5&since_ms=1714938000000' | jqVerify local chain
covenant audit verify
curl -s 127.0.0.1:8421/audit/verify \
-H "Authorization: Bearer $COVENANT_OPERATOR_TOKEN" | jqFilter for capability checks that failed
tail -F ~/.covenant/audit/events.jsonl \
| jq -c 'select(.kind.type == "capability_check" and .kind.passed == false)'Find every dispatch for a specific agent
jq -c 'select(.kind.type == "intent_dispatched"
and .kind.matched_agent == "research")' \
~/.covenant/audit/events.jsonlTrust model
The audit log is local. A user with write access to $COVENANT_HOME can rewrite history. The local hash-chain detects retained-row edits and sidecar mismatch after anchoring, and covenant verify surfaces cross-reference drift. This is not public signing or immutable storage.
Deployments where the operator is not the sole writer to the host should either sign individual events or stream the log to an append-only system with the appropriate trust model. Both approaches integrate against the existing AuditLog trait.
Related
- Audit integrity: local hash-chain verification and its limits.
- Capability tokens: where grants and checks originate.
- CLI:
verifyand its drift-check rules. - Security model: what the local-trust assumption costs you.