Skip to content
Updated Sep 12, 2026 by Barča Dvořáková · Owner: analysisactiveconceptgenericapi-a Edit on GitHub

Audit Log — API Analysis (API-A) ​

Concept layer — frozen. The Audit Log generic. Nothing here is written by a normal playbook run; a project's own feature analysis is the live document and takes every edit. This layer names no project and links to none — the dependency runs one way, from an application to its concept.

Feature: Audit Log

Paths below are written without any version or gateway prefix: how a project versions and mounts the routes is an application decision, not part of the concept. Success envelope: { data, status, message?, requestId }. Error envelope: { status, error: { code, message, details }, requestId }.

The trail is read-only over the API. Entries are written exclusively by the system's own code through the internal write port documented at the end of this page; there are no mutation endpoints, because a trail an API client can write to records claims rather than events.


Two decisions this surface forces ​

One read endpoint or two? Where the system is scoped, a reader may need their own scope, one named scope, or every scope at once — and the last two are a different privilege from the first.

  • Two endpoints — an ordinary read that derives the scope from the caller's context and can never widen, and a privileged read that takes a scope filter and can reach events belonging to no scope. The isolation is structural: the ordinary endpoint has no parameter that could breach it, so no filter combination and no defect in filter handling can leak another scope's entries. The cost is two near-identical endpoints to document and keep in step.
  • One endpoint with an optional scope parameter, whose permitted values depend on what the caller holds. One contract, one set of parameters — and isolation now rests entirely on a correct permission check, so every future parameter is a chance to get it wrong.

This page documents the read as a single endpoint and notes where the privileged variant differs; a project that splits it repeats the parameter table on both.

Cursor or page/pageSize? The trail is append-only and new entries insert ahead of any paginated view, so the two options fail differently.

  • Cursor — an opaque token anchored to the last row read. Stable under concurrent inserts: a reader paging through a busy trail never sees a row twice and never skips one. The cost is no jump-to-page and, usually, no total count.
  • Page / pageSize — gives the investigation surface the page numbers and totals it renders, at the cost of rows shifting between pages while a reader pages through them.

Either is defensible; the choice usually follows the project's house convention for list endpoints rather than this feature. What is not optional is a deterministic total order: sorting by event time alone lets equal timestamps duplicate or drop a row within a page, so the sort carries a tiebreaker on the entry id. This page uses page/pageSize for its examples.


1. Read the trail ​

Endpoint: GET /audit-log

Description ​

Lists entries newest first, filtered and paginated. Backs both reading surfaces: the cross-entity investigation table when nothing is pinned, and a record's own history when the record is pinned.

Authorization ​

Authorisation follows the shape of the request, not the URL:

  • A request that pins a record — entity type + id, or subject type + id — requires the read permission registered for that entity type on this surface: the same permission that governs the record's own screen. A caller who may read the record may read its history.
  • A request that pins nothing requires the audit read permission for this surface.
  • Where no read permission is registered for the pinned entity type — an entity type with no read surface anywhere — the request requires the audit read permission even though it pins a record. The pin narrows the result; it does not lower the bar.

A caller holding only an entity read permission who omits the pin is rejected, not served a filtered result, so the pin cannot be dropped to widen a query. Scope isolation applies underneath either path.

Request Headers ​

text
Authorization: Bearer <access_token>
Content-Type: application/json

Query Parameters ​

ParameterTypeRequiredDescription
entityTypeStringNoFilter by the type of the audited record
entityIdStringNoFilter by the audited record's id. Requires entityType, matching the composite index
subjectEntityTypeStringNoFilter by the type of record the entries are about
subjectEntityIdStringNoFilter by the subject record's id. Requires subjectEntityType
eventTypeStringNoFilter by what happened
actorStringNoFilter by the triggering actor
correlationIdStringNoReturn every entry produced by one action
identifierStringNoThe "find" input: matched exactly against the record id, the subject id, the action id and the record's resolved human key; composes with every other filter. Not a text search over the payload
from / toTimestampNoInclusive bounds on the event time
feedVisibleOnlyBooleanNoRestrict to entries marked feed-visible. Defaults to off, so the investigation surface is unaffected
includeMachineActorsBooleanNoInclude entries attributed to machines. Defaults to off
groupByCorrelationBooleanNoOne item per action instead of one per entry: entries sharing an action id are nested under a headline item, page/pageSize select actions, and totalCount counts distinct actions. Defaults to off — flat rows
scopeStringNoPrivileged variant only — one scope, or the marker for events belonging to none

The subject pair mirrors the record pair exactly, including the "id requires type" rule, so there is one convention to learn rather than two.

An entity id is a text value, and validation must not assume a UUID. The reference deliberately carries no foreign key, so it can point at anything — including seeded rows whose identifiers only look like UUIDs, and concepts whose "id" is a stable configuration code rather than a row id at all. Enforcing UUID syntax on entityId or subjectEntityId rejects the only id those entities have and makes them unreadable through the API while their entries sit in the trail. Accepting any non-empty string is not laxity: there is nothing to validate the value against, and an id matching no row simply returns an empty page. UUID syntax is checked only where a parameter genuinely is a UUID.

Request Logic ​

  • Authorisation is resolved before filtering, from the presence of the pins.
  • Select entries in the caller's scope, apply the filters, order newest first with a tiebreaker on the entry id.
  • Every filter above except the two visibility flags is a column filter backed by an index; the visibility flags compile to predicates over the event vocabulary, the payload and the actor form. Both are applied inside the query, not after it, or pagination and totals describe a result set the reader never sees. Because they are not index-backed, a read relying on them alone is as wide as the scope it runs in.
  • Resolve the derived description for every row — description key, its parameters, severity, and the actor's display name. Resolving the actor is an outer join, so an entry whose actor has since been deleted is still returned; the same join belongs in the count query, or the total and the page disagree.
  • A subject pin returns entries about that record regardless of which record each is filed against. This is what backs a per-subject history — a person's own record changes, their memberships and the assignments made to them — in one query rather than several.
  • A correlation pin returns every entry produced by one action, regardless of which record each is filed against. It composes with every other filter, so one action can be narrowed to one entity type or one time window within it.
  • Neither pin weakens scope isolation. An action spanning scopes returns only the caller's share of it through the ordinary read; the complete view is a privileged capability.

Success Response (200 OK) ​

json
{
  "data": {
    "items": [
      {
        "id": "018fa51f-fda1-79f4-8461-2cb8f1cabc10",
        "scope": null,
        "entityType": "template",
        "entityId": "018fa51f-1c22-7c05-9a70-3f0b0f2f77aa",
        "subjectEntityType": null,
        "subjectEntityId": null,
        "eventType": "activated",
        "context": {
          "templateCode": "orderConfirmation",
          "version": 4
        },
        "correlationId": "9a4f0f7c-0b19-4c6a-9a45-6c9b4a1f6f21",
        "descriptionKey": "event.activated",
        "descriptionParams": {},
        "severity": "info",
        "actorDisplayName": "A. Editor",
        "occurredAt": "2026-07-13T00:00:00Z",
        "actor": "user:018fa51f-9a10-7f31-8bd4-2c1a9f5e0d33"
      },
      {
        "id": "018fa51f-fda1-79f4-8461-2cb8f1cabc11",
        "scope": "018fa51f-4d31-7a02-9f18-0b7c2e5a11bc",
        "entityType": "customer",
        "entityId": "018fa51f-77a4-7b60-8e2c-91d0f3b0a7de",
        "subjectEntityType": "customer",
        "subjectEntityId": "018fa51f-77a4-7b60-8e2c-91d0f3b0a7de",
        "eventType": "updated",
        "context": {
          "changedFields": {
            "name": { "before": "Example Trading", "after": "Example Trading Group" }
          }
        },
        "correlationId": "9a4f0f7c-0b19-4c6a-9a45-6c9b4a1f6f21",
        "descriptionKey": "customer.activity.updated.name",
        "descriptionParams": { "before": "Example Trading", "after": "Example Trading Group" },
        "severity": "info",
        "actorDisplayName": "A. Editor",
        "occurredAt": "2026-05-12T14:03:00Z",
        "actor": "user:018fa51f-9a10-7f31-8bd4-2c1a9f5e0d33"
      }
    ],
    "pagination": { "page": 1, "pageSize": 25, "totalCount": 2, "totalPages": 1 }
  },
  "status": 200,
  "requestId": "a8123fa1-2045-42b4-83fc-97dafe11f4a5"
}

The two entries illustrate both description paths — the first has no registered description and falls back to the translated event name with empty parameters, the second has one with its interpolation values. They also show both subject conventions: an entity type that does not self-subject leaves the subject empty, one that does repeats its own reference there, and both entries share one action identifier because one save produced them.

Response Data Mapping ​

Response FieldSource / Value
id, scope, entityType, entityIdDirect columns on the trail
subjectEntityType, subjectEntityIdDirect columns; empty only where the entry declares no subject at all
eventTypeThe shared event vocabulary
contextThe structured payload, shaped by the writing feature
correlationIdDirect column; empty for an entry written outside a request context
entityKeyDerived — the record's human-readable key (a document's number, a set's name), resolved per entity type by a best-effort outer join on the record's primary key; empty where the type has no key or the record no longer exists. Shown beside the id, never instead of it. Cannot add or drop an entry or change the total
groupedEntries, groupSizeDerived — present only with groupByCorrelation: the entries of this action, newest first, the headline item included; every one reachable, per the reachability rule
descriptionKeyDerived from (entity type, event type); falls back to the translated event name
descriptionParamsDerived — values read from the payload at the declared paths; an absent path is omitted, not emitted as null
severityDerived; ordinary severity when nothing is registered
actorDisplayNameDerived — the person's name, a generic label per kind of machine actor, or the raw actor value when it cannot be resolved at all. Whether it is snapshotted at write or resolved at read is the decision under Rendering on the feature page; when resolved, the read also returns the account's deactivated/deleted state so a client can mark it
occurredAt, actorDirect columns
pagination.*Echoes of the paging parameters plus the derived totals

Derivation is total. An unregistered event, a missing payload path or a malformed payload degrades to the fallbacks above and is logged; none of them is returned as an error, so one bad row cannot fail a page.

Error Responses ​

400 — invalid query. An unknown event or entity-type value, a malformed timestamp, an actor not in the expected form, a page size out of range, a non-boolean flag, an id supplied without its type, or a combination of scope parameters that is mutually exclusive. Every failing field is reported.

json
{
  "status": 400,
  "error": {
    "code": "ERR_AUDIT_LOG_INVALID_QUERY",
    "message": "Invalid query parameters.",
    "details": {
      "fieldErrors": [
        { "field": "entityId", "issue": "Requires entityType to be set." },
        { "field": "feedVisibleOnly", "issue": "Must be a boolean." }
      ]
    }
  },
  "requestId": "3ab34d88-65d1-4c10-897a-237c9a5b116f"
}

403 — forbidden. The caller does not hold the permission the request as issued requires: the entity type's read permission for a pinned request, the audit read permission otherwise. A caller holding only an entity read permission who omits the pin is rejected here rather than served a filtered result. The response names the permission that was required.

json
{
  "status": 403,
  "error": {
    "code": "ERR_AUDIT_LOG_FORBIDDEN",
    "message": "You do not have permission to read the audit log.",
    "details": { "requiredPermission": "<audit read permission>" }
  },
  "requestId": "16ddc487-1092-496e-b9a4-97d3e3082ee6"
}

1a. Enumerate the actors in a scope ​

Endpoint: GET /audit-log/actors

Description ​

The actor is an opaque sentinel — a person's identifier, a job's name — that no screen can show a person, so a free-text actor filter is usable only by someone who already knows the internal value. This read returns the distinct actors present in the caller's scope, each with its resolved display name and, for people, the account's active/deleted state, so the actor filter can be a picker. Machine actors sort after people.

Authorization ​

Exactly as the unpinned read: the audit read permission for this surface. A caller who may browse a trail may enumerate who wrote to it; one who may not must not learn who exists from the picker. The endpoint takes no pins and therefore never resolves to an entity's read permission.

Request Logic ​

Distinct actors over the caller's scope (the privileged variant takes the same scope parameter as the list read), resolved through the same join the list uses. Where the read fails or returns nothing the filter degrades to free text rather than disappearing.


2. Internal write port (no endpoint) ​

Entries are written only by the system's own use cases, through a port with two operations and no third:

typescript
interface AuditLogPort {
  save(entry: AuditLogEntry, options?: TransactionOptions): Promise<void>;
  list(query: ListAuditLogQuery): Promise<{ rows: AuditLogEntry[]; totalCount: number }>;
}
  • The port exposes no update and no delete. Append-only is a property of the interface, not a convention writers are asked to respect: what cannot be called cannot be called by mistake.
  • save accepts the caller's transaction context. Whether it is invoked inside that transaction, after it commits, or through an outbox is the decision recorded under Transactional Operations on the feature page — the port supports all three, the project states which it uses, and it may state a different one for change entries and for event entries. Because the transaction is optional here, the guarantee is made at every call site: a test over the writers, not the port, is what enforces it.
  • The port also resolves centrally what no writer should have to remember: the action identifier from the request context, and the self-subject for the entity types declared to carry one.
  • Redaction, where a project needs one at all, is deliberately not on this port. It is an out-of-band operation for legally mandated erasure, not something a use case can reach.