# Agent action audit record: a practical model for control

Agent actions need records that describe authority, not just activity. A line such as `POST /deploy returned 200` cannot tell an investigator whether the right process made the request, whether a person allowed it, which system received it, or whether a later revoke stopped anything.

The practical unit is an immutable action record joined to session, approval, and revocation events. Keep the action record small enough to search and verify, but specific enough that a tired engineer can reconstruct a disputed call without inventing a story around missing fields.

## An audit entry must describe an authorized attempt

An **agent action audit record** captures one attempted effect on an external system and the authority context present at that instant. An action can fail before it reaches the network, fail during delivery, receive a refusal from the target, or complete successfully. All four cases belong in the audit trail.

Teams often log one of two inadequate things. They log a chat transcript, which describes intent but not execution. Or they log raw request traffic, which produces too much secret material and still misses the human decision that permitted the call. Neither record answers whether a particular actor had authority to perform a particular operation.

Treat these as separate objects:

- A **session** identifies one running agent process and its lifetime.
- An **approval** records a human decision with a defined scope.
- An **action** records one requested external operation and its result.
- A **revocation** ends a session or authorization at a precise time.

The distinction has a consequence during an incident. If a person revokes a session at 14:03, you need to see every action before 14:03, every attempted action after 14:03, and the local decision that denied those later attempts. A single mutable row labeled "session approved: false" destroys that history.

Do not make the record model depend on whether the action succeeded. The request itself can show malicious intent, a configuration defect, or an agent that misunderstood its task. Failed calls are often the first useful evidence.

## The actor is a process, not a friendly agent label

Record the process that actually requested the action. A model name, workspace name, or agent nickname may help a human scan a report, but none uniquely identifies the executable that held the connection at the time.

A useful actor object contains an internal session identifier, process ID, executable path, launch timestamp, and code signing authority when the operating system provides it. Include the parent process ID and parent executable when you can collect them reliably. The parent relationship often explains whether a terminal launched the agent, an editor extension spawned it, or an unexpected helper did.

Use a process identity snapshot rather than resolving the process later. Process IDs get reused. Paths can change. A later lookup cannot repair a missing observation.

```json
{
  "actor": {
    "session_id": "ses_01J8Q1F9K9Z3",
    "pid": 48217,
    "started_at": "2025-02-18T21:14:06Z",
    "executable": "/usr/local/bin/agent-runner",
    "signing_authority": "Developer ID Application: Example Developer",
    "parent_pid": 48091,
    "parent_executable": "/Applications/Terminal.app"
  }
}
```

The value in `signing_authority` should come from the platform's signing assessment, not a string supplied by the agent. An agent can call itself `claude-code`, `deploy-helper`, or `trusted-agent`. A label is decoration unless the operating system ties it to an executable identity.

Do not turn process identity into a false claim of authorship. A signed binary can act badly after a prompt injection, a compromised plugin, or a careless instruction. The field establishes which code authority initiated the request. It does not certify the wisdom of the request.

RFC 5424, the syslog protocol specification, separates header fields such as application name and process ID from structured data. That separation still makes sense for agent records. Put stable identity and timing in dedicated fields that machines can query. Put variable context, such as repository or task reference, in a namespaced object. When everything goes into one message string, every later investigation becomes text parsing.

## A target needs both an address and a meaning

Record where an action went and what resource or command it sought to affect. Those are related facts, but they are not interchangeable.

For HTTP, the network destination might be `api.example.internal`, while the meaningful operation is `POST /v1/releases/{release_id}/promote`. Store the host, port, protocol, HTTP method, and a route template. Add a target system identifier that your team controls, such as `release-service-prod`. The identifier survives a hostname migration; the hostname helps diagnose the request that actually left the machine.

For SSH, store the host alias or host name, port, host key fingerprint or a reference to the known-host entry, and remote account label where that label is safe to retain. The target should also name the requested command class. `restart-worker` explains more than `ssh succeeded`, yet it reveals less than a full shell command containing customer paths and environment variables.

Use redaction before persistence, not after an analyst opens the log. URL query strings, request headers, shell arguments, and JSON bodies regularly contain tokens. A logging library that captures them "for debugging" will eventually produce an incident file full of credentials.

This record shape keeps target facts queryable without copying request content wholesale:

```json
{
  "target": {
    "kind": "http",
    "system_id": "release-service-prod",
    "endpoint": {
      "scheme": "https",
      "host": "api.example.internal",
      "port": 443,
      "method": "POST",
      "route_template": "/v1/releases/{release_id}/promote"
    }
  },
  "operation": {
    "name": "promote_release",
    "request_fingerprint": "sha256:8e8c...",
    "request_bytes": 286,
    "redacted_parameters": {
      "environment": "production",
      "release_id": "rel_7b2"
    }
  }
}
```

The fingerprint only helps if you define canonicalization. Sort object fields, remove fields your redaction policy excludes, normalize text encoding, and then hash the resulting bytes. Record the canonicalization version. Otherwise two equivalent requests can produce unrelated digests, and a future schema change can make old records look suspect.

A digest cannot replace retained evidence when a regulator, contract, or incident process requires the original request. In that case, encrypt the evidence separately, restrict access, and store its content digest in the action record. Do not place the original body in the ordinary searchable journal merely because storage is cheap.

## Requested operation and observed result need different fields

An agent request expresses intent. The result reports what the gateway observed after trying to execute that intent. Do not merge them into a vague status such as `completed`.

For a network call, record the transport phase where execution stopped, the protocol status where one exists, a bounded response summary, and the time spent. For SSH, record connection outcome, authentication outcome, remote exit code, and bounded standard output and error summaries. A target can accept a request and perform an asynchronous job later, so a 202 response does not mean the external change occurred.

Use an outcome vocabulary that states where the failure happened. For example:

- `denied_vault_locked` means the local secret boundary refused the call.
- `denied_approval` means the required human decision did not permit it.
- `network_error` means the gateway could not establish or retain a connection.
- `target_rejected` means the remote service returned a refusal.
- `target_accepted` means the remote service accepted the request.

Keep the raw transport error out of the primary status field. Put a normalized code such as `dns_lookup_failed` or `tls_validation_failed` beside a short, redacted diagnostic. Engineers need aggregation; incident responders need enough local context to tell an expired certificate from a blocked hostname.

A complete result object might look like this:

```json
{
  "result": {
    "outcome": "target_accepted",
    "started_at": "2025-02-18T21:19:42.184Z",
    "finished_at": "2025-02-18T21:19:43.021Z",
    "duration_ms": 837,
    "http_status": 202,
    "response_fingerprint": "sha256:2a64...",
    "response_summary": "promotion job accepted",
    "evidence_ref": null
  }
}
```

Avoid calling `target_accepted` a success in your schema. That word causes trouble later. If the service accepts a job and the job fails, the gateway did exactly what it should have done, while the business operation did not finish. A separate remote completion event, correlated by a job ID, can answer that later question.

## Approval records must state what the person approved

An approval record needs a decision, a scope, a subject, and timestamps. "User approved agent" says too little. It leaves reviewers guessing whether the person authorized one API call, a particular credential, a session, or every future session from a similarly named process.

A session approval commonly permits calls by one identified process until that process exits or someone revokes it. A per-call approval applies to one use of one credential or operation. Record the scope directly, because the two controls produce very different risk.

```json
{
  "approval": {
    "approval_id": "apr_01J8Q1P4Y5D6",
    "decision": "approved",
    "scope": "session",
    "subject_session_id": "ses_01J8Q1F9K9Z3",
    "approved_at": "2025-02-18T21:14:11Z",
    "expires_at": "2025-02-18T22:02:53Z",
    "approver_presence": "local_user_confirmation"
  }
}
```

Do not claim more certainty than your interface can provide. If the application receives a local confirmation event, record that fact. Do not record a person's name, identity provider account, or biometric method unless the system truly authenticates and retains that association under a documented policy. Invented identity detail creates false confidence and creates privacy obligations.

Store the approval that governed an action as an identifier on the action record. Also store the authorization decision that occurred immediately before execution. This looks repetitive until you investigate a timing edge case: an old session approval may exist, but a per-call requirement may deny the action. The action needs both facts.

Approval fatigue is a design defect, not a reason to omit approval evidence. If a person must approve every harmless request, they will approve without reading. Reserve per-call confirmation for credentials or operations whose misuse needs direct attention, and make the record say why the gateway asked.

## Revocation is an event with a cutoff time

Revocation ends authority going forward. It does not erase a session, retract an already delivered request, or change the approval decision that existed five minutes earlier.

Record the revocation target, the initiator type, the observed timestamp, and the enforcement result. If the gateway can terminate or block an active session, record whether it did so. If an action already crossed the network boundary, say that the revoke cannot recall it. Engineers need that unpleasant fact during response, not a comforting but false `revoked` label.

Consider this failure sequence:

1. An agent process receives a session approval at 09:00 and submits a production change at 09:17.
2. The operator notices an unexpected target and revokes the session at 09:18:04.
3. The target responds to the 09:17 request at 09:18:07 because it had already queued work.
4. The agent attempts another call at 09:18:09, and the gateway denies it.

A good journal preserves all four events. The action at 09:17 was authorized when it began. The response after revocation belongs to that earlier action. The denied attempt proves that the revoke took effect for later work. If you stamp every earlier action "revoked," you lose the sequence that explains the actual exposure.

Use a monotonic sequence number in addition to wall-clock time. Clocks can drift, users can change local time, and events can share a timestamp resolution. A sequence number establishes the order in which the journal accepted records. If you operate across hosts, preserve each host's local sequence and use correlation IDs rather than pretending that wall clocks establish a perfect global order.

## Tamper evidence depends on append-only discipline

A hash chain makes undetected editing harder by including the prior record's digest in each new record. It does not turn an ordinary log file into proof that every expected event exists. Someone who controls the writer and its stored chain head may delete a suffix, start a new chain, or prevent records from reaching durable storage.

That limitation does not make hash chains pointless. They answer a narrower, useful question: did these records change, disappear from the middle, or arrive in a different order after the system wrote them? Keep the chain fields explicit.

```json
{
  "journal": {
    "sequence": 1842,
    "recorded_at": "2025-02-18T21:19:43.024Z",
    "previous_hash": "sha256:68b1...",
    "record_hash": "sha256:93f4...",
    "hash_format": "canonical-json-v1"
  }
}
```

Hash the complete canonical record excluding only `record_hash` itself. Do not hash a pretty printed representation whose whitespace, field order, or timestamp formatting changes between versions. Version the canonical format and retain the verifier for every format you emit.

NIST Special Publication 800-92, Guide to Computer Security Log Management, treats log generation, storage, analysis, and retention as separate responsibilities. That distinction cuts through a frequent mistake: teams add a hash to entries and call the job finished. You still need durable storage, access controls around the writer, a retention decision, regular verification, and an investigation procedure for a failed check.

Sallyport projects its Sessions and Activity journals from one write-blind encrypted, hash-chained audit log. Its `sp audit verify` command verifies the chain offline over ciphertext without requiring a vault key. That design separates a reader's ability to check journal integrity from the ability to use credentials.

## A single correlation ID makes the record usable under pressure

Give every requested action an action ID before the gateway evaluates authorization. Carry it through the local decision, the network attempt, response handling, and any later remote completion callback. When an engineer sees a disputed deployment, they should search one identifier and retrieve the full timeline.

Use related IDs for different relationships. The action points to its session, approval, credential reference, target, and journal sequence. A task or conversation reference can point from the action back to agent context, but do not make the conversation transcript the source of truth. Prompt text changes, may contain private material, and often fails to describe the eventual request precisely.

A compact complete record can use this shape:

```json
{
  "schema_version": "1.0",
  "action_id": "act_01J8Q2ABR8M7",
  "event_type": "action.completed",
  "actor": {"session_id": "ses_01J8Q1F9K9Z3", "pid": 48217},
  "target": {"kind": "http", "system_id": "release-service-prod"},
  "operation": {"name": "promote_release", "request_fingerprint": "sha256:8e8c..."},
  "authorization": {"vault": "unlocked", "approval_id": "apr_01J8Q1P4Y5D6", "decision": "approved"},
  "result": {"outcome": "target_accepted", "http_status": 202},
  "journal": {"sequence": 1842, "previous_hash": "sha256:68b1..."}
}
```

Do not use the correlation ID as an authorization token. Generate it independently, make it unpredictable where external callers can observe it, and never accept possession of the ID as permission to read an action or issue a follow-up call.

## Retention must preserve evidence without creating a second secret store

Audit data accumulates private material even when you redact aggressively. Target names can reveal customers, route parameters can reveal internal projects, response summaries can expose account state, and process paths can expose developer habits. Decide who may search records, who may export them, and how long each category remains available.

Keep searchable action metadata separate from any encrypted request or response evidence. Apply shorter retention to detailed evidence when the investigation need expires. Preserve hashes and linkage records long enough to prove that remaining summaries still correspond to what the system recorded.

Test the model with a real question from an incident review: "Which signed process requested access to this production target, under which approval, and did any attempt continue after revoke?" If a query needs an analyst to join unstructured messages, inspect client debug logs, or ask the agent what it remembers, the model is incomplete.

Sallyport's fixed decision ladder gives this record model clean boundaries: the vault gate, session authorization, and per-call credential approval each produce a distinct authorization fact. Keep those facts distinct in your own audit design. The record should show where authority stopped, rather than burying every denial under a generic failure code.

Build the schema before agents gain broad credentials. Retrofitting actor identity, approval scope, and revoke ordering after an incident means reconstructing authority from gaps. That is slow work, and it usually ends with someone saying "we think" when the record should have said exactly what happened.
