Appearance
Audit Log — API Analysis (API-A)
📥 Endpoint: GET /v1/audit-log
Investigation surface: Query the audit trail across every entity, across every tenant the caller can access. One endpoint, on platform-backend — there is no separate tenant-scoped endpoint on backend. A caller entitled to exactly one tenant simply has a resolved scope of size one; the fan-out below degenerates to a single tenant's read for them, it is not a different code path.
Authorization
Gated by a single permission grant on this endpoint whose scope is either global (every tenant) or an explicit, enumerated set of tenantIds — the same shape already sketched for cross-tenant person lookup as userTenantAccess (userId, tenantId, permissionLevel) (docs/porsenna/features/users-access/cross-tenant-person-lookup.md). The concrete storage and resolution of that grant is a different epic's concern (Roles & Permissions) — see Design Notes.
Request Headers
None. No shared API-conventions page exists yet in this project.
Request Body
None.
Request Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
identifier | string | No | The "find" input: matched exactly against id, entityId, subjectEntityId and actionId — whichever of the four it is, without the caller having to know which. A value that doesn't parse as a UUID matches nothing (a correct empty result, not an error): this is an exact match over the four id columns, not a search over a business key or label — see identifier matches four uuid columns only in Design Notes. Composes with every other filter below. Not a text search over logMetadata. |
entityType | string | No | Filter by entity type. Comma-separated for multiple (OR'd), e.g. building,contract. Omit to search all types. |
entityId | string (UUID) | No | Filter by record ID. Comma-separated for multiple (OR'd), e.g. two ids to compare their histories. |
subjectEntityType | string | No | Filter by the type of record the entries are about, when that differs from entityType/entityId — e.g. a membership entry filed against the group but about the member. Not comma-separated: a subject pin, not a browsing filter. |
subjectEntityId | string (UUID) | No | Filter by the subject record's ID. Requires subjectEntityType, mirroring entityId's "id requires type" rule. |
event | string | No | Filter by event type. Comma-separated for multiple (OR'd), e.g. created,deleted. See Enum - AuditLogEvent. |
createdBy | string | No | Filter by actor. Comma-separated for multiple (OR'd), e.g. user:2cd4482b-5391-46b2-b491-69a1ee949fa6,system:jitmigration. Named createdBy to match the entity's own column — this is the entry's standard audit-metadata actor, not a bespoke field. Populate the picker from GET /v1/audit-log/actors below rather than free text. |
tenantId | string (UUID) | No | Narrow to specific tenants. Comma-separated for multiple (OR'd), e.g. 550e8400-e29b-41d4-a716-446655440004,550e8400-e29b-41d4-a716-446655440005. Only narrows: values are intersected with the caller's resolved tenant scope, never widened by it (see Request Logic step 1). Populate the picker from GET /v1/audit-log/tenants below. Mirrors clientId on the cross-tenant connectors endpoint. |
startTime | string (ISO 8601) | No | Include entries with createdAt from this time onwards (inclusive). Defaults to 30 days before endTime only when endTime is also omitted — supplying endTime alone leaves startTime unbounded (the entire trail up to endTime), not 30 days before it. See Default date range in Design Notes. |
endTime | string (ISO 8601) | No | Include entries with createdAt up to this time (inclusive). Defaults to now when omitted. |
actionId | string (UUID) | No | Filter by action identifier. Comma-separated for multiple (OR'd). |
groupByCorrelation | boolean | No | Default false (flat rows, one per entry). When true, entries sharing an actionId are nested one level under a single headline item; page/pageSize then page actions across the merged set, and meta.total counts distinct actions, not rows. |
page | integer | No | Page number, 1-indexed (default: 1). |
pageSize | integer | No | Results per page (default: 50, max: 100). |
The caller's own resolved tenant scope decides which tenants may be queried (see Request Logic); tenantId can only narrow within it, never reach beyond it.
Request Logic
- Resolve the caller's accessible tenant scope from their audit-log permission grant: global scope resolves to every active tenant; a scoped grant resolves to exactly its enumerated tenantIds — for an ordinary caller entitled to only their own tenant, this is a scope of size one. This is a read of whatever the Roles & Permissions work exposes for that grant, not a re-derivation from
platform.tenant_usersmembership the wayGET /v1/me/tenantsworks for the tenant switcher — tenant access and this endpoint's audit-read permission are related but distinct grants. IftenantIdis supplied, the effective scope is the intersection of the resolved scope and the supplied ids. A supplied id outside the resolved scope is ignored — no 403 and no signal that the tenant exists; if the intersection is empty the result is an empty page (HTTP 200), not an error and not the full scope. A malformed (non-UUID) value is a 400, as for the other UUID filters. - For each tenant in the effective scope, in turn — not in parallel, mirroring
apps/platform-backend/src/core/aggregator-admin'sListAllConnectorsUseCase's sequential per-tenant reads, so one slow or broken tenant schema doesn't fan out into N concurrent connections — open that tenant's own scoped connection and apply every filter below identically. - Apply all provided filters as
WHEREconditions (AND logic across parameters; OR logic within a comma-separated parameter's values), including the resolvedstartTime/endTimewindow — the bound must be inside theWHEREclause, never applied only to the response. The window resolves as: both omitted → last 30 days up to now;endTimesupplied,startTimeomitted → unbounded start up toendTime(deliberate — an explicitendTimeis read as "show me everything up to here", not "show me 30 days ending here");startTimesupplied → used as given,endTimedefaults to now. - Resolve
identifier, when present, asid = $identifier OR entity_id = $identifier OR subject_entity_id = $identifier OR action_id = $identifier, cast to UUID. A non-UUID-shaped value is normalised to a UUID that can never match any row, rather than being ignored as "no filter" — theidentifierarm stays engaged, so a business key or other non-UUID paste yields a correct empty page, never a silent "no filter applied". - A
subjectEntityType/subjectEntityIdpin returns every entry filed about that record, regardless of which record each is filed against — the same query a future per-record history surface (out of scope for this analysis) would reuse without a schema change. - A tenant whose read throws is skipped and logged, not fatal — the rest of the resolved scope still returns, HTTP 200. A fan-out past a warn threshold (mirroring the connectors precedent's
FANOUT_WARN_THRESHOLD = 200) is logged once per request. For a resolved scope of size one this reduces to "the one tenant's read succeeds or the request fails" — no separate behaviour to maintain for that case. - Merge every tenant's rows, sort by
createdAt DESCacross the merged set (not per-tenant), then paginate the merged, sorted list —page/pageSizeapply to the combined result, not per-tenant. WithoutgroupByCorrelation: order bycreatedAt DESC, page-slice the merged set directly. WithgroupByCorrelation: select the distinctactionIds matching the filters above across every tenant read (newest member first), paginate those, then fetch every entry for the page'sactionIds and nest them under their action. - Every row's
tenantIdis echoed in the response unchanged from the base entity — the investigation surface's tenant column/filter reads directly from it. - Every entry's
entityName/subjectEntityName/tenantName,descriptionKey/descriptionParams,severity,actorDisplayName/actorKindandmetadataNamesare derived at read time by a separate enrichment pass over the page (see Response Data Mapping and Name enrichment in Design Notes) — never stored on the row, never joined into the query above.
Representative SQL for one tenant's own read within the fan-out (flat, groupByCorrelation=false):
sql
SELECT
id,
entity_type,
entity_id,
subject_entity_type,
subject_entity_id,
event,
log_metadata,
action_id,
tenant_id,
created_at,
updated_at,
deleted_at,
created_by,
updated_by
FROM audit_log
WHERE tenant_id = $0::uuid -- the tenant currently being read, from the resolved scope
AND ($1::text[] IS NULL OR entity_type = ANY($1))
AND ($2::uuid[] IS NULL OR entity_id = ANY($2))
AND ($3::text[] IS NULL OR event = ANY($3))
AND ($4::text[] IS NULL OR created_by = ANY($4))
AND created_at >= $5::timestamp
AND created_at <= $6::timestamp
AND ($7::uuid[] IS NULL OR action_id = ANY($7))
AND ($9::text IS NULL OR subject_entity_type = $9)
AND ($10::uuid IS NULL OR subject_entity_id = $10)
AND (
$8::text IS NULL
OR id::text = $8 OR entity_id::text = $8 OR subject_entity_id::text = $8 OR action_id::text = $8
)
ORDER BY created_at DESC;
-- run once per tenant in the resolved scope (a scope of size one runs it once); the
-- sort/page/pageSize applied afterward act on the merged rows from every tenant read,
-- never on any one tenant's own query$1, $2, $3, $4, $7 are arrays built by splitting each comma-separated parameter; a single value is a one-element array. $5/$6 are always populated, resolved per the window rule in Request Logic step 3 above (30 days back from endTime only when the caller supplied neither bound). $9/$10 are the subject pin, singular rather than array-valued — it narrows to one record's own history, not a browsable set. identifier ($8) matches only the four uuid columns shown; there is no per-entity-type human-key resolver in this query.
The groupByCorrelation=true variant runs the same per-tenant WHERE clause against a SELECT DISTINCT action_id, MAX(created_at) AS newest (excluding null action_ids, which cannot be grouped) for each tenant, merges those across the resolved scope, paginates that, then re-queries entries WHERE action_id = ANY(<page's ids>) (per originating tenant) for the nested list.
Transactional Operations
N/A — read-only; no transaction management required.
✅ Success Response
Status: 200 OK
json
{
"status": 200,
"message": "OK",
"requestId": "req-2026-09-11-001",
"data": [
{
"id": "550e8400-e29b-41d4-a716-446655440001",
"entityType": "contract",
"entityId": "550e8400-e29b-41d4-a716-446655440002",
"entityName": "Acme Gas Supply a.s. (2026-01-01 – 2026-12-31)",
"subjectEntityType": null,
"subjectEntityId": null,
"subjectEntityName": null,
"event": "updated",
"logMetadata": {
"before": { "status": "active", "supplierId": "550e8400-e29b-41d4-a716-446655440006" },
"after": { "status": "inactive", "supplierId": "550e8400-e29b-41d4-a716-446655440006" },
"changedFields": ["status"]
},
"descriptionKey": "auditLog.description.contract.updated",
"descriptionParams": { "status": "inactive" },
"severity": "ordinary",
"actionId": "550e8400-e29b-41d4-a716-446655440003",
"tenantId": "550e8400-e29b-41d4-a716-446655440004",
"createdAt": "2026-09-11T14:23:45Z",
"updatedAt": "2026-09-11T14:23:45Z",
"deletedAt": null,
"createdBy": "user:2cd4482b-5391-46b2-b491-69a1ee949fa6",
"actorDisplayName": "Jana Nováková",
"actorKind": "user",
"updatedBy": "user:2cd4482b-5391-46b2-b491-69a1ee949fa6",
"tenantName": "Acme Energy s.r.o.",
"metadataNames": { "550e8400-e29b-41d4-a716-446655440006": "Acme Gas Supply a.s." }
},
{
"id": "550e8400-e29b-41d4-a716-446655440005",
"entityType": "contract",
"entityId": "550e8400-e29b-41d4-a716-446655440002",
"entityName": "Acme Gas Supply a.s. (2026-01-01 – 2026-12-31)",
"subjectEntityType": null,
"subjectEntityId": null,
"subjectEntityName": null,
"event": "created",
"logMetadata": {
"before": null,
"after": { "supplierId": "550e8400-e29b-41d4-a716-446655440006", "status": "active" },
"changedFields": ["supplierId", "status"]
},
"descriptionKey": "auditLog.description.contract.created",
"descriptionParams": {},
"severity": "ordinary",
"actionId": "550e8400-e29b-41d4-a716-446655440007",
"tenantId": "550e8400-e29b-41d4-a716-446655440004",
"createdAt": "2026-09-11T14:20:00Z",
"updatedAt": "2026-09-11T14:20:00Z",
"deletedAt": null,
"createdBy": "system:jitmigration",
"actorDisplayName": "System — JIT Migration",
"actorKind": "system",
"updatedBy": "system:jitmigration",
"tenantName": "Acme Energy s.r.o.",
"metadataNames": {}
}
],
"meta": {
"page": 1,
"pageSize": 50,
"total": 42,
"hasNext": true
}
}meta.total describes the merged result across the caller's whole resolved tenant scope, not any one tenant's own count — for a scope of size one, that is simply the one tenant's own count. meta.hasNext is computed as (page - 1) * pageSize + items.length < total. With groupByCorrelation=true, each item in data additionally carries groupedEntries (the action's own entries, newest first, headline item included) and groupSize; meta.total/meta.hasNext then count/consider actions, not rows.
Response Data Mapping
| Response Field | Source / Value |
|---|---|
data[*].id | audit_log.id |
data[*].entityType | audit_log.entity_type |
data[*].entityId | audit_log.entity_id |
data[*].subjectEntityType, data[*].subjectEntityId | audit_log.subject_entity_type, audit_log.subject_entity_id — populated only where the writing feature declared a subject for the entry; null otherwise. |
data[*].entityName, data[*].subjectEntityName | Derived — a display-name lookup for entityId and (when present) subjectEntityId, resolved at read time against a small, maintained per-entity-type registry (architecture 61-audit-log.md §7.4). null when unresolved (entity type not registered in the lookup, or the record has been hard-deleted) — the row is still fully renderable via entityId. A soft-deleted record's name still resolves; only a hard delete loses it. |
data[*].event | audit_log.event |
data[*].logMetadata | audit_log.log_metadata (JSON, as-is) — see JSON-DAT: auditLog.logMetadata |
data[*].descriptionKey | Derived from (entityType, event); falls back to the translated event name when the entity has registered no entity-specific description. |
data[*].descriptionParams | Derived — values read from logMetadata at paths the writing feature registered; an absent path is omitted, never emitted as null. |
data[*].severity | Derived; ordinary when the writing feature registered nothing more specific. |
data[*].actionId | audit_log.action_id |
data[*].tenantId | audit_log.tenant_id — echoed unchanged from whichever tenant's schema the row was read from; the investigation surface's tenant column/filter reads directly from it. |
data[*].createdAt | audit_log.created_at (ISO 8601 UTC) — the audited change's own timestamp. |
data[*].updatedAt | audit_log.updated_at — always equal to createdAt. |
data[*].deletedAt | audit_log.deleted_at — always null. |
data[*].createdBy | audit_log.created_by (type:actor) — the audited change's own actor. |
data[*].actorDisplayName | Derived at read time (this project's choice — see Design Notes): the person's current name, a fixed label per kind of machine actor, or the raw createdBy value when it cannot be resolved. For a person, also carries the account's active/deactivated/deleted state so the interface can mark it. |
data[*].updatedBy | audit_log.updated_by — always equal to createdBy. |
data[*].actorKind | Derived from the createdBy prefix (user: / system:) — user or system. |
data[*].tenantName | Derived — the display name of whichever tenant's schema the row was read from. |
data[*].metadataNames | Derived — a value→name map for any UUIDs declared inside logMetadata, resolved at read time; {} when the entry declares none. Never absent. |
data[*].groupedEntries, data[*].groupSize | Present only with groupByCorrelation=true — this action's own entries (see Request Logic), every one reachable through the parent item, none hidden. |
meta.page | Page number returned (request parameter, defaults to 1) |
meta.pageSize | Rows/actions per page (request parameter, defaults to 50, max 100) |
meta.total | Total matching rows across the resolved tenant scope, or total matching actions with groupByCorrelation=true |
meta.hasNext | Whether a further page exists — (page - 1) * pageSize + items.length < total |
❌ Error Responses
400 Bad Request
Returned when filter parameters are invalid (e.g., malformed UUID, invalid ISO 8601 timestamp).
json
{
"status": 400,
"errorCode": "INVALID_REQUEST",
"errorMessage": "Invalid entityId format; expected UUID.",
"requestId": "req-2026-09-11-002"
}403 Forbidden
Returned when the caller does not hold the audit-log read permission.
json
{
"status": 403,
"errorCode": "PERMISSION_DENIED",
"errorMessage": "This operation requires the audit-log read permission.",
"requestId": "req-2026-09-11-003"
}500 Internal Server Error
Returned when a query fails (e.g., database error). A single unreachable tenant mid-fan-out is not surfaced as an error to the caller — see Request Logic #6; this is reserved for a failure that prevents the request as a whole.
json
{
"status": 500,
"errorCode": "INTERNAL_ERROR",
"errorMessage": "Failed to query audit log.",
"requestId": "req-2026-09-11-004"
}📥 Endpoint: GET /v1/audit-log/actors
Enumerate the actors across the caller's resolved tenant scope, so the createdBy filter above can be a picker instead of free text — an actor identifier (user:<uuid>, system:<name>) is not something a person can be expected to type from memory.
Authorization
Exactly the same permission the list endpoint requires — never a separate capability. A caller who may browse the trail may enumerate who has written to it; one who may not must not learn who exists from the picker.
Request Headers
None. No shared API-conventions page exists yet in this project — same as the list endpoint above.
Request Body
None.
Request Parameters
None. Always the caller's own resolved tenant scope, exactly as the list endpoint resolves it — this endpoint takes no pins, so it never resolves to any entity's own read permission.
Request Logic
- Resolve the caller's accessible tenant scope exactly as the list endpoint does (Request Logic step 1 above).
- For each tenant in that scope, distinct
createdByvalues inauditLog, resolved through the same actor-display join the list endpoint uses; merge and de-duplicate across tenants — the same actor identifier can appear in more than one tenant's schema (e.g. a shared system actor, or a person with access to several). - People sort before machine actors; within each, alphabetically by display name.
- If the resolving join fails or returns nothing for a given actor, that actor is still listed (raw
createdByvalue, no display name) rather than dropped — the picker degrades to showing the raw form, it never silently omits an actor who has written entries.
sql
SELECT DISTINCT created_by
FROM audit_log
WHERE tenant_id = $0::uuid; -- run once per tenant in the resolved scope, then merged/de-duplicated
-- each created_by then resolved via the same actor-display lookup the list endpoint's
-- actorDisplayName column uses, including active/deactivated/deleted state for peopleTransactional Operations
N/A — read-only; no transaction management required.
✅ Success Response
Status: 200 OK
json
{
"status": 200,
"message": "OK",
"requestId": "req-2026-09-11-005",
"data": {
"items": [
{
"actor": "user:2cd4482b-5391-46b2-b491-69a1ee949fa6",
"kind": "user",
"displayName": "Jana Nováková",
"status": "active"
},
{
"actor": "system:jitmigration",
"kind": "system",
"displayName": "System — JIT Migration",
"status": null
}
]
}
}Response Data Mapping
| Response Field | Source / Value |
|---|---|
data.items | This endpoint is not paginated (no page/pageSize/meta — see Request Parameters); its handler returns { items: [...] } directly, one level deeper than the list endpoint's flat data array. |
data.items[*].actor | Distinct audit_log.created_by, merged across the resolved tenant scope |
data.items[*].kind | Derived from the createdBy prefix (user: / system:) |
data.items[*].displayName | Derived via the actor-display lookup; the raw actor value when it cannot be resolved |
data.items[*].status | For kind: "user": active / deactivated / deleted. null for kind: "system" — machine actors have no such state. |
❌ Error Responses
403 Forbidden
Same shape as the list endpoint's 403.
📥 Endpoint: GET /v1/audit-log/tenants
Enumerate the tenants in the caller's resolved tenant scope, so the tenantId filter above can be a picker, and so the UI knows whether to show the tenant column and filter at all (more than one tenant in the list) without inferring it from the rows on the current page.
Authorization
Exactly the same permission the list endpoint requires — never a separate capability. A caller who may not browse the trail must not learn which tenants exist from the picker.
Request Headers
None. No shared API-conventions page exists yet in this project — same as the list endpoint above.
Request Body
None.
Request Parameters
None. Always the caller's own resolved tenant scope, exactly as the list endpoint resolves it.
Request Logic
- Resolve the caller's accessible tenant scope exactly as the list endpoint does (Request Logic step 1 above): global scope resolves to every active tenant, a scoped grant to exactly its enumerated tenantIds. This is not the tenant-switcher list from
GET /v1/me/tenants— a tenant the caller can switch into but has no audit-read grant for is absent, and the reverse. - Every tenant in the scope is listed, whether or not it has any audit entries yet — the list comes from the grant, not from the trail, and no per-tenant read of
audit_logis made. - Sorted alphabetically by
name. - A tenant whose name cannot be resolved is still listed (
nameis the rawid) rather than dropped — same degradation as the actors picker.
Transactional Operations
N/A — read-only; no transaction management required.
✅ Success Response
Status: 200 OK
json
{
"status": 200,
"message": "OK",
"requestId": "req-2026-10-05-001",
"data": {
"items": [
{ "id": "550e8400-e29b-41d4-a716-446655440004", "name": "Acme Energy s.r.o." },
{ "id": "550e8400-e29b-41d4-a716-446655440005", "name": "Beta Heating a.s." }
]
}
}Response Data Mapping
| Response Field | Source / Value |
|---|---|
data.items | Not paginated (no page/pageSize/meta) — the handler returns { items: [...] } directly, same nesting as the actors endpoint. For a global-scope caller this is every active tenant, so the picker needs its own search box. |
data.items[*].id | Tenant id from the resolved scope — the value the tenantId filter takes. |
data.items[*].name | Current tenant display name; the same value the list endpoint echoes as tenantName. |
❌ Error Responses
403 Forbidden
Same shape as the list endpoint's 403.
Internal Access — writing an entry
Not a public endpoint: every other write path in EM3 (HTTP handler, background job, CLI migration) writes directly to auditLog, inside its own transaction, as a side effect of the change it records. There is no POST /audit-log — the trail cannot be written to from outside the application. See the feature's own Write Path (Functional Requirements) and Transactional Operations for the write-side guarantee, including the distinction between a change entry (rides the transaction of the row it describes) and an event entry (no row write to ride; the insert is the fact) that a future non-CRUD event value may need.
Design Notes
Pagination: Required on every read of the list endpoint.
pageSizedefaults to 50 and caps at 100 to prevent unbounded result sets on large audit tables;pagedefaults to 1 (1-indexed).Filtering: All filters are optional, combined with AND logic across parameters.
entityType,entityId,event,createdBy,tenantIdandactionIdeach accept comma-separated multiple values, OR'd together — chosen over aPOST /audit-log/searchbody because every filter here is a flat equality/membership check, well within URL length limits.tenantIdnarrows, never widens, and out-of-scope ids are ignored rather than rejected. The caller's resolved scope is the authority; the filter is a convenience inside it. Ignoring (instead of a 403) means the filter's response never reveals whether a tenant id exists or is merely outside the caller's grant. A narrowed request also reads fewer tenant schemas in the fan-out, so it is cheaper than the unfiltered one.Tenant column/filter visibility comes from
GET /v1/audit-log/tenants, not from the loaded page. Counting distinct tenants on the current page makes the column appear and disappear page to page for a multi-tenant caller; the scope list is the stable signal.The
identifierfield exists because the other filters assume the caller already knows which kind of id they're holding. An investigator usually arrives with exactly one value — a record id, an action id, or a subject id — and nothing else; making them guess the right parameter first is a worse interface than one exact-match field that checks all of them.identifiermatches four uuid columns only. It is an exact match againstid,entityId,subjectEntityIdandactionId— never a search by a business key, a label, or any other human-readable attribute (a contract number, a serial number). Pasting one of those intoidentifierreturns an empty page, by design, rather than a fuzzy or partial match. The need to make a bare uuid readable is served separately:entityName/subjectEntityNameon the response (see Name enrichment below) give the caller the record's display name without their having to search by it.Default date range: the list endpoint bounds an omitted
startTime/endTimeto the last 30 days — but only when both are omitted. SupplyingendTimealone leavesstartTimeunbounded (everything up toendTime), on the reading that a caller who names an explicit end point is asking "show me everything up to here", not "show me a 30-day slice ending here" — see Request Logic step 3. This is a UI-facing default (visible and removable in the investigation surface), chosen independently of the retention/partitioning horizon under Performance in the feature doc, which remains undecided — the two should be reconciled once that horizon is set, since a partitioned table only prunes efficiently when a query carries the partition key, and this default is what makes that true for the common case.One endpoint, not a tenant-scoped/cross-tenant split. An earlier draft of this doc specified a separate
GET /api/v1/audit-logonbackend, RLS-bound to the caller's own tenant, alongside a cross-tenant endpoint onplatform-backend. That split is gone: every caller reads through this one endpoint, and a caller entitled to exactly one tenant simply gets a resolved scope of size one — the fan-out is the same code path for everyone, not a privileged variant.Why
platform-backend, notbackend: a resolved scope can span more than one tenant, so this endpoint needs to open one connection per tenant schema in turn — exactly the shapeplatform-backend's existing cross-tenant admin surfaces (aggregator-admin) already use.backend's own connections are inherently single-tenant (RLS-bound tocurrent_setting('app.tenant_id')from the ambient session), with no precedent for opening a second tenant's connection mid-request, so this endpoint lives onplatform-backendeven for a caller whose resolved scope happens to be one tenant.Ordering: Results are newest-first (
createdAt DESC) across the merged, resolved-scope result. This makes recent changes visible without paging deep.Grouping is available both ways. The investigation surface always displays entries sharing an
actionIdas one expandable row (see the feature doc's UI/UX Design) whether or notgroupByCorrelationis set; the parameter exists so the server can do the grouping and paginate by action, rather than the client re-grouping a flat, arbitrarily-cut page of rows.Actor format: Distinguishes human (
user:<uuid>) from machine (system:<name>) actors, enabling filtering and presentation logic to differ by type.Actor display name — resolved at read, not snapshotted.
auditLogstores only thecreatedBy/updatedByidentifier, never a name;actorDisplayNameis derived from the current directory at read time. This project accepts that a rename relabels history (the entry still says who, unambiguously, via the identifier) in exchange for one copy of the name and no personal-data duplication into the fastest-growing table in the system — see Cybersecurity Considerations in the feature doc. The resolving join carries the account's active/deactivated/deleted state, and must be present in the count query too, not only the page query, or the total and the page disagree.Subject reference (
subjectEntityType/subjectEntityId): carried on every entry per the generic concept, for an entry filed against one record but concerning another (a membership filed against the group, about the member), or for a type that names itself as its own subject so its full history — including entries filed against its children — is one indexed read. Most entities in this project's current roster have now adopted one of the three shapes: subject to a parent (e.g.document/responsiblePersonAssignmenttobuilding,contractItemtocontract,readingActionLogtoreading), self-subject (e.g.file,physicalMeter), or an explicit no-subject decision (e.g.sector) — each entity's own DAT states which and why.identifieralready matches a populatedsubjectEntityIdfor any of them.Action ID grouping key: Entries sharing an
actionIdbelong to one decision (one request, one job).Name enrichment — read-time, best-effort, page-scoped.
entityName,subjectEntityName,tenantName, resolvedactorDisplayName/actorKindandmetadataNames(names for uuid values found inside a page's ownlogMetadata) are never stored on the row and never joined into the query in Request Logic — a dedicated pass resolves them afterward, over only the rendered page's own references. A name is never stored; a renamed entity always shows its current name, and a hard-deleted entity's name is unrecoverable (id-only, forever) while a soft-deleted one still resolves. An entity type not yet registered in the lookup renders id-only — a valid, permanent state, not a bug — and a failed lookup for one tenant, one actor batch, or one metadata field costs only those names; every row still renders. Full design: architecture61-audit-log.md§7 in the code repo. Which entities are currently registered, and what each resolves to, is this project's own concern — see each entity's own DAT (## Audited fields→ entityName resolution).The actors endpoint's
datais not a flat array. Unlike the list endpoint,GET /v1/audit-log/actorsis not paginated, so its handler returns{ items: [...] }directly rather than calling the paginate helper the list endpoint uses — the envelope'sdatatherefore nests one level deeper (data.items, nometa) than the list endpoint'sdataarray. See the Success Response above.Casing: the real table is
audit_log(columnsentity_type,tenant_id, …) — plain, unquoted, singular snake_case, the same convention every em3 table follows (docs/architecture/47-data-table-conventions.mdin the code repo). Earlier drafts of this doc modeled the table with quoted camelCase identifiers as a one-table exception; that plan was never carried into the migration, and the SQL above now matches what shipped. The JSON response and this DAT's Attribute Name column stay camelCase — that's the project-wide documentation/API convention (entityType,tenantId, …), not a reflection of the physical schema; see the entity DAT's naming note and the overlay's naming-convention entry.Known limitation, same as the connectors precedent: a tenant that fails mid-fan-out silently shrinks the result —
metahas no field today to signal a partial read. Accepted for the same reason it was accepted there: an intermittently-unreachable tenant schema is rare, and a slow-but-complete 200 beats a hard 500 for every other tenant's data.Permission model — the mechanism is a different epic's concern. Authorization here is one permission grant per caller, whose scope is either global (every tenant) or an explicit, enumerated tenantId set — not a choice between
aggregator-admin's dedicated-admin-code precedent andme/tenants's implicit-membership precedent, as an earlier draft of this doc framed it. Neither existing code pattern is the model: this is the sameuserTenantAccess (userId, tenantId, permissionLevel)shape already sketched for cross-tenant person lookup (docs/porsenna/features/users-access/cross-tenant-person-lookup.md) — "a role/flag such as 'all-tenants read', not per-tenant enumeration of grants" for the global case, and one row per tenant for the scoped case ("e.g. a regional manager with access to 4 specific tenants is the same mechanism with 4 rows instead of an all-tenants flag"). That doc already flags this as "generally reusable outside the helpdesk case" — this endpoint is exactly such a reuse.The concrete storage and resolution of the grant — the generic cross-tenant permission primitive itself, including the revision of whatever partial implementation exists today — is out of scope for this epic. It is being defined under separate Roles & Permissions work. This endpoint's own responsibility is narrower: read the caller's resolved scope (global, or an explicit tenantId list, which for an ordinary caller is just their own tenant) from whatever that work exposes, and fan out only across that set. It does not own, store, or choose between models for the permission itself.