# Audit log

**The audit log answers "who did what, to what, when, from where, and with what outcome."** It is the one record in hlix that carries a guarantee: every entry is written, no entry can be edited or deleted, and each Workspace's entries are hash-chained, so a tampered or missing entry is detectable.

## Prerequisites

- The `owner` or `admin` role in the Workspace. Every audit route refuses `member` and `client` alike — see [Roles & permissions](/security/roles-and-permissions/#the-audit-log).
- An API key and your Workspace ID from [Developer tools → API keys](https://app.hlix.ai/developer-tools/api-keys), or the **Audit Log** screen in the [dashboard](/running/dashboard/).

## Three records, one promise

hlix keeps three separate records, and the difference between them is the point.

| Record | Answers | Delivery |
| --- | --- | --- |
| Audit log | Who did what, to what, when, from where, with what outcome | Guaranteed and immutable |
| Events | What is happening right now | Best-effort |
| Agent runs and logs | Why a run cost or failed what it did | Best-effort |

**Only the audit log is guaranteed:** The activity feed and the run logs are best-effort, so a gap in either is not evidence that nothing happened. Base compliance evidence on the audit log.

## What gets recorded

Every action has a stable `resource.verb` name — `project.created`, `secret.config.updated`, `task.executed` — and each name maps to one of seven categories you can filter on.

| Category | What lands here |
| --- | --- |
| `create` | New projects, tasks, cycles, members, collaborators, customer accounts, invoices, secrets, environments, agents, packs, skills, memories, deployments, integrations, and a deliverable approval being requested |
| `update` | Changes to any of the above, plus settings, billing, provider keys, model and harness preferences, DNA, and every deliverable transition — publishing, unpublishing, approval and changes-requested are all recorded as updates |
| `delete` | Removal of any of the above |
| `execute` | Work being run: task and cycle execution, dispatch, deploys, approvals resolved, policy decisions, sandbox actions, integration runs |
| `auth` | Sign-in, sign-out, API key created, API key revoked, passkey added |
| `read` | The deliberately narrow set of reads worth recording — billing and secret access |
| `export` | Data leaving the Workspace: invoice exports and data exports |

Reads are **not** logged in general. Only billing and secret access are recorded, because there the read itself is the sensitive act.

Each entry carries the actor (a person, an agent, or the system, with the acting user attributed where an agent acted on someone's behalf), the resource and its parent, the change itself, the source and calling IP and user agent, the outcome and severity, an optional reason, and the correlation ids below.

### Secrets never reach an entry intact

Before an entry is written, its change and metadata are redacted:

- Any field whose **name** looks like a credential — anything matching secret, token, password, API key, authorization, cookie or credential — becomes `[redacted]`.
- Any value that **looks** like a credential is replaced by shape, not by name: provider key prefixes, JSON web tokens, bearer tokens, connection strings carrying a password, private-key blocks, and labelled key/value pairs.
- Long strings are truncated.

Redaction is a safety net, not a reason to put credentials in a payload: an encoded, split or unfamiliar value may not match.

## Reading it

```bash
curl -sS "$HLIX_BASE_URL/v1/api/audit-log?category=delete&limit=25" \
  -H "x-api-key: $HLIX_API_KEY" \
  -H "X-Organization-Id: $HLIX_WORKSPACE_ID"
```

Expected result: `{"items": [...], "nextCursor": "…"}` — entries newest-first, and a cursor that is `null` on the last page.

Filter on `actorType`, `actorId`, `action`, `category`, `resourceType`, `resourceId`, `outcome`, `severity`, `source`, `requestId`, `traceId`, and a `from`/`to` time range. `limit` defaults to 50 and is capped at 200.

**An unknown action filter is ignored, not rejected:** Passing an `action` the server does not recognise does not fail the request — it comes back **unfiltered**. If a filtered query returns far more than you expected, check the action name against the [API reference](/api/reference/audit-log/) before reading anything into the result.

Paging is keyset, not offset: pass the previous page's `nextCursor` back verbatim. The cursor is opaque — never construct one.

A project-scoped listing is available at `/v1/api/audit-log/projects/{projectId}`, and it is still `owner`/`admin` only. Narrowing what you can read does not widen who may read it.

### One entry, with its correlated trace

```bash
curl -sS "$HLIX_BASE_URL/v1/api/audit-log/$ENTRY_ID" \
  -H "x-api-key: $HLIX_API_KEY" \
  -H "X-Organization-Id: $HLIX_WORKSPACE_ID"
```

Expected result: the entry plus a `correlated` object holding the events, agent runs and logs that share its trace.

Three correlation ids make that join possible. `requestId` identifies one API call. `traceId` identifies one unit of work across all three records. `spanId` identifies a step within it. An entry with no trace comes back with an empty `correlated` object rather than an error.

## Verifying integrity

Two independent mechanisms sit under the guarantee.

**Append-only storage.** Entries cannot be updated or deleted by hlix or anyone else. Expiring old entries at the end of the retention period never rewrites current ones.

**A per-Workspace hash chain.** Each entry is hashed together with the hash of the previous entry in that Workspace. Removing an entry breaks the chain; altering one changes its hash. Check it:

```bash
curl -sS "$HLIX_BASE_URL/v1/api/audit-log/verify" \
  -H "x-api-key: $HLIX_API_KEY" \
  -H "X-Organization-Id: $HLIX_WORKSPACE_ID"
```

Expected result on an intact chain:

```json
{"ok": true, "checked": 1842, "total": 1842, "firstBadId": null}
```

**A 200 does not mean the chain is intact:** The request succeeding only means the check ran. **Read `ok`.** And compare `checked` against `total`: a walk that stops short means an entry is missing from the chain even when every entry it did reach hashed correctly.

### Compliance-critical changes commit with their record

For the subset of mutations where the record matters as much as the change — provider key changes, secret configuration, environment configuration, and archiving, unarchiving or deleting a project — the change and its audit entry are saved **together, atomically**. The record cannot exist without the change, or the change without the record.

For every other action the entry is written as part of handling the request, but a failure to write it does not fail the request.

## Retention

| Record | Kept for |
| --- | --- |
| Audit log | 24 months |
| Events (the activity feed) | 6 months |
| Execution logs | 30 days |
| Agent runs | Not pruned — kept for the life of the Workspace |

Expired entries are removed daily. Retention periods are the same for every Workspace.

## If the audit log does not answer what you expect

- **`{"error": "admin only"}` with `403`.** Every audit route needs `owner` or `admin`. A `member` gets this, and so does a client collaborator. There is no narrower view to fall back to.
- **`{"error": "Audit entry not found"}` with `404`.** The id does not exist in this Workspace. Confirm the `X-Organization-Id` header matches the Workspace the entry came from — an entry from another Workspace is not-found, not forbidden.
- **`{"ok": false, ...}` with a `firstBadId`.** An entry does not hash to its recorded value. Capture the response, stop writing to that Workspace, and escalate — this is the condition the chain exists to surface.
- **`ok` is `true` but `checked` is less than `total`.** The walk ran out of chain before it ran out of entries. Treat it exactly as the case above.
- **A filtered query returned everything.** An `action` the server does not know is ignored rather than rejected. Check the spelling against the [API reference](/api/reference/audit-log/).
- **A change you made is not in the log.** Check the category first — general reads are not recorded by design, and only billing and secret access are. If it was a write, look for it under the resource's own action name rather than the screen you made it from.
- **An entry has no correlated events or logs.** Those two records are best-effort and are pruned sooner (6 months and 30 days). An older audit entry outliving its correlated records is expected.
- **An agent appears as the actor and you want the person behind it.** Read the attributed user on the entry; an agent acting on someone's behalf records both.

## Where to go next

| If you want to… | Read |
| --- | --- |
| See the exact request and response shapes | [Audit log API](/api/reference/audit-log/) |
| Understand who may read this | [Roles & permissions](/security/roles-and-permissions/) |
| See the gates that produce these records | [Review & QA gates](/concepts/review-gates/) |
| Read it in the dashboard | [The dashboard](/running/dashboard/) |

## Next steps

[Roles & permissions](/security/roles-and-permissions/)
  [Tenancy & isolation](/security/tenancy/)
  [Review & QA gates](/concepts/review-gates/)
  [Audit log API](/api/reference/audit-log/)