21.1 History vs Audit
ADL provides two change-recording systems. They serve different audiences and coexist on the same resource.
| Audit Trail (§15.3) | History of Change | |
|---|---|---|
| Audience | Developers, DBAs, operations | End users, compliance officers, business stakeholders |
| Format | JSON field diffs: { "status": { "from": "draft", "to": "published" } } | Human-readable narratives: “Article published by Alice” |
| Granularity | Every field change on every operation | Business-significant events only (domain author declares which) |
| Configuration | features.audit: true + optional audit: block | history: block with explicit event declarations |
| Purpose | Debugging, forensic reconstruction, data recovery | Activity feeds, compliance logs, customer-facing timelines |
| Table | {domain}_{resource}_audit | {domain}_{resource}_history |
Audit is a debugger. History is a storyteller. Both can be enabled simultaneously — audit records mechanical field diffs for developers while history records semantic business events for users.
21.2 Activation
History is activated by declaring a history: block on a resource:
resources:
Ticket:
features:
softDelete: true
audit: true
history:
events:
created:
on: create
capture: [title, priority, reporterName]
label: "Ticket created by {reporterName}"
icon: lucide:plus-circle
severity: info
The presence of the history: block activates the history system for this resource. There is no separate features.history: true flag — the history: block itself is the activation.
This creates:
- A
{domain}_{resource}_historytable - A
GET /:id/historyendpoint returning a paginated timeline for one record - A
GET /{resource}/historyendpoint returning a cross-record event stream - Runtime hooks that write events on declared triggers
- Bus events emitted after each history record is written
21.3 Event Declaration
Events are declared in the history.events block. Each event has a name (the key) and a set of properties that define when it fires, what it captures, and how it is displayed.
history:
events:
assigned:
on: transition
transition: assign
capture: [assigneeId, assigneeName]
label: "Assigned to {assigneeName}"
icon: lucide:user-plus
severity: info
priorityChanged:
on: fieldChange
field: priority
capture: [priority]
label: "Priority changed to {priority}"
icon: lucide:flag
severity: info
resolved:
on: transition
transition: resolve
capture: [resolution]
label: "Resolved"
detail: "{resolution}"
icon: lucide:check-circle
severity: success
Event Properties
| Property | Type | Required | Default | Description |
|---|---|---|---|---|
on | string | yes | — | Trigger type: create, update, delete, restore, transition, fieldChange |
transition | string | if on: transition | — | State machine transition name |
field | string | if on: fieldChange | — | Field name to watch for changes |
fields | string[] | no | — | For on: update — fire only when these fields change |
capture | string[] | no | [] | Field names to snapshot in the event data |
label | string | yes | — | Human-readable label with {fieldName} interpolation |
detail | string | no | "" | Extended description with {fieldName} interpolation |
icon | string | no | "" | Icon reference for UI rendering (e.g., lucide:check-circle) |
severity | string | no | "info" | info, success, warning, error |
roles | string[] | no | [] | Restrict event visibility to these roles (empty = visible to all) |
condition | string | no | "" | AEL expression — event fires only when true |
snapshot | boolean | no | false | When true and versioning: true, link event to a full record version |
21.4 Trigger Types
Six triggers cover every mutation path in the ADL runtime.
on: create
Fires after a successful create operation. The full created record is available for capture.
created:
on: create
capture: [title, priority, reporterName]
label: "Ticket created by {reporterName}"
on: update
Fires after a successful update (PATCH/PUT). The optional fields array restricts the trigger to fire only when specific fields change.
detailsUpdated:
on: update
fields: [title, description]
capture: [title]
label: "Ticket details updated"
Without fields, fires on every update — usually too noisy. Use on: fieldChange for individual field events and reserve on: update with fields for broad “record modified” events.
on: delete
Fires after a successful delete (hard or soft). For soft delete, the deletedAt timestamp is captured implicitly.
deleted:
on: delete
label: "Ticket deleted"
severity: warning
on: restore
Fires after a soft-deleted record is restored. Only meaningful when softDelete: true.
restored:
on: restore
label: "Ticket restored from deletion"
on: transition
Fires after a successful state machine transition. Requires transition: to name the specific transition. The from and to states are captured automatically — they do not need to be in capture.
resolved:
on: transition
transition: resolve
capture: [resolution]
label: "Resolved"
detail: "{resolution}"
on: fieldChange
Fires when a specific field’s value changes during an update. The old and new values are captured automatically. Additional fields can be included via capture.
priorityChanged:
on: fieldChange
field: priority
capture: [priority]
label: "Priority changed to {priority}"
The difference from on: update with fields: is semantic: fieldChange events represent a single named business occurrence (“the priority was changed”), while update events represent a broader modification.
21.5 Label Interpolation
Labels and details support {fieldName} placeholders resolved against the captured data at write time:
label: "Assigned to {assigneeName}"
# Stored as: "Assigned to Sarah"
detail: "Resolution: {resolution}"
# Stored as: "Resolution: Optimized the query and added an index"
Pre-Interpolated Storage
Labels are resolved and stored as plain strings, not as templates. This means:
- The history API is a flat
SELECT— no joins, no template processing, no runtime resolution - If Sarah changes her display name later, old events still say “Sarah” — this is correct for an event log, which records what was true at the time
- Missing or null fields resolve to empty strings — labels should read naturally even when placeholders are empty
Interpolation Context
Placeholders resolve against three data sources in priority order:
- Captured fields — values from the
capturearray - Automatic captures —
fromandtostates for transitions; old and new values for field changes - Actor data —
{user.name},{user.id},{user.email}from the request identity
21.6 Severity Levels
Each event has a severity that controls visual rendering in the UI:
| Severity | Color Convention | Use For |
|---|---|---|
info | Blue/gray | Routine operations: created, updated, assigned |
success | Green | Positive outcomes: approved, published, resolved |
warning | Amber/orange | Attention needed: reopened, blocked, escalated |
error | Red | Problems: SLA breached, rejected, failed |
Severity is a UI rendering hint. It has no behavioral effect on the platform.
21.7 Conditional Events
Events can include an AEL condition expression. The event fires only when the condition evaluates to true against the record state at trigger time.
slaBreached:
on: fieldChange
field: status
condition: "status == 'in_progress' && dueAt != null && dueAt < now()"
capture: [dueAt, assigneeId]
label: "SLA breached — past due date"
icon: lucide:alert-octagon
severity: error
This transforms the history system from a passive recorder into an active policy monitor. The system evaluates business rules and flags violations automatically — the domain author declares the condition, and the runtime records the event only when the condition is true.
The condition expression uses the validation context (§36.4) — $this (current record state), $old (previous state on update), and AEL functions.
21.8 Role-Scoped Visibility
Events can be restricted to specific roles using the roles property:
internalNote:
on: fieldChange
field: internalNotes
capture: [internalNotes]
label: "Internal note added"
roles: [agent, admin]
icon: lucide:lock
severity: info
The event is always recorded in the history table — role scoping does not prevent recording, it controls who can read the event. The GET /:id/history endpoint filters events by the requesting user’s roles. A customer querying the ticket’s history sees all events except internalNote.
When roles is empty (the default), the event is visible to all users with get permission on the resource.
21.9 Data Capture
The capture array specifies which field values to snapshot into the event record:
assigned:
on: transition
transition: assign
capture: [assigneeId, assigneeName, priority]
Captured values are stored as a JSON object in the event record’s data column:
{
"assigneeId": "user-sarah",
"assigneeName": "Sarah",
"priority": "high"
}
Automatic Captures
Some data is captured automatically based on the trigger type:
| Trigger | Auto-Captured | Description |
|---|---|---|
on: transition | from, to | Previous and new state values |
on: fieldChange | previousValue, newValue | Previous and new field values |
| All triggers | actorId, actorName | The user who performed the operation |
Automatic captures do not need to be listed in the capture array. They are always present in the event data.
Previous Values
For on: fieldChange and on: update, the event data includes both old and new values for the declared fields. This is captured from the same change-detection logic that powers the audit trail.
21.10 History API
Per-Record Timeline
GET /api/v1/tickets/tickets/:id/history
Returns a paginated timeline of events for a single record, sorted by timestamp (newest first by default):
{
"data": {
"items": [
{
"id": "evt-001",
"event": "resolved",
"label": "Resolved",
"detail": "Optimized the database query and added an index",
"icon": "lucide:check-circle",
"severity": "success",
"data": {
"resolution": "Optimized the database query and added an index",
"from": "in_progress",
"to": "resolved"
},
"actorId": "user-sarah",
"actorName": "Sarah",
"recordId": "ticket-001",
"createdAt": "2026-05-17T14:30:00Z"
},
{
"id": "evt-002",
"event": "assigned",
"label": "Assigned to Sarah",
"icon": "lucide:user-plus",
"severity": "info",
"data": {
"assigneeId": "user-sarah",
"assigneeName": "Sarah"
},
"actorId": "user-admin",
"actorName": "Admin",
"recordId": "ticket-001",
"createdAt": "2026-05-17T10:00:00Z"
}
],
"count": 2,
"totalCount": 8
}
}
Query Parameters
| Parameter | Type | Description |
|---|---|---|
page | integer | Page number (default 1) |
limit | integer | Items per page (default from display.defaultLimit, max from display.maxLimit) |
event | string | Filter by event name: ?event=resolved |
severity | string | Filter by severity: ?severity=error |
after | datetime | Events after this timestamp |
before | datetime | Events before this timestamp |
actorId | uuid | Events by a specific user |
Cross-Record Event Stream
GET /api/v1/tickets/tickets/history
Returns events across all records of the resource, supporting the same query parameters plus:
| Parameter | Type | Description |
|---|---|---|
recordId | uuid | Filter to events for a specific record (equivalent to the per-record endpoint) |
21.11 Database Schema
The history table structure:
CREATE TABLE {domain}_{resource}_history (
id TEXT PRIMARY KEY,
recordId TEXT NOT NULL,
event TEXT NOT NULL,
label TEXT NOT NULL,
detail TEXT,
icon TEXT,
severity TEXT DEFAULT 'info',
data TEXT, -- JSON: captured fields
actorId TEXT,
actorName TEXT,
roles TEXT, -- JSON array: visibility restriction
createdAt TEXT NOT NULL,
FOREIGN KEY (recordId) REFERENCES {domain}_{resource}(id)
)
Storage Estimates
Each history event is approximately 200–500 bytes depending on captured data. A resource with 10,000 records averaging 15 events each produces ~75–150 MB of history data.
Retention
History records MAY have a retention policy:
history:
retention:
days: 365
Records older than retention.days are eligible for automated cleanup. If retention is omitted, history records are permanent.
21.12 Bus Event Emission
After a history record is written, the runtime emits a bus event:
adl.{domain}.{resource}.history.{eventName}
Example: adl.tickets.ticket.history.resolved
The event payload includes the complete history record. This enables downstream systems to react to business events:
- Notifications (§23) subscribe to history events to trigger alerts
- Workflows (§25) subscribe to history events to advance process steps
- Integrations (§35) subscribe to history events to trigger outbound API calls
- Scheduled tasks (§24) can reference history events in their conditions
This is the architectural foundation that makes History the prerequisite for every other Intelligence Layer feature. History events are the lingua franca of the platform’s reactive systems.
21.13 Display Configuration
The display: block configures the history API endpoints:
history:
display:
endpoint: true
defaultLimit: 50
maxLimit: 200
order: desc
includeActor: true
groupBy: date
| Key | Type | Default | Description |
|---|---|---|---|
endpoint | boolean | true | Expose the GET /:id/history endpoint |
defaultLimit | integer | 50 | Default page size for history queries |
maxLimit | integer | 200 | Maximum page size |
order | string | "desc" | Default sort: "desc" (newest first) or "asc" (oldest first) |
includeActor | boolean | true | Include actorId and actorName in every event |
groupBy | string | "" | Group events in the response: "date" (by day), "event" (by event type), or empty (flat list) |
21.14 Complete Example
A full history declaration for a ticket management system:
resources:
Ticket:
history:
events:
created:
on: create
capture: [title, priority, reporterName]
label: "Ticket created by {reporterName}"
icon: lucide:plus-circle
severity: info
deleted:
on: delete
label: "Ticket deleted"
icon: lucide:trash-2
severity: warning
restored:
on: restore
label: "Ticket restored"
icon: lucide:undo-2
severity: info
assigned:
on: transition
transition: assign
capture: [assigneeName]
label: "Assigned to {assigneeName}"
icon: lucide:user-plus
severity: info
resolved:
on: transition
transition: resolve
capture: [resolution]
label: "Resolved"
detail: "{resolution}"
icon: lucide:check-circle
severity: success
closed:
on: transition
transition: close
label: "Ticket closed"
icon: lucide:archive
severity: success
reopened:
on: transition
transition: reopen
label: "Ticket reopened"
icon: lucide:rotate-ccw
severity: warning
priorityChanged:
on: fieldChange
field: priority
capture: [priority]
label: "Priority changed to {priority}"
icon: lucide:flag
severity: info
reassigned:
on: fieldChange
field: assigneeId
capture: [assigneeName]
label: "Reassigned to {assigneeName}"
icon: lucide:user-check
severity: info
slaBreached:
on: fieldChange
field: status
condition: "status == 'in_progress' && dueAt != null && dueAt < now()"
capture: [dueAt, assigneeId]
label: "SLA breached — past due date"
icon: lucide:alert-octagon
severity: error
internalNote:
on: fieldChange
field: internalNotes
capture: [internalNotes]
label: "Internal note added"
roles: [agent, admin]
icon: lucide:lock
severity: info
display:
endpoint: true
defaultLimit: 50
maxLimit: 200
order: desc
includeActor: true
groupBy: date
retention:
days: 730
This declaration produces a complete, queryable, role-filtered, human-readable activity timeline for every ticket — with zero application code.
21.15 Integration Points
History is the foundation that other Intelligence Layer features build upon:
| Feature | How It Uses History |
|---|---|
| Notifications (§23) | Subscribes to adl.*.*.history.* bus events to trigger alerts |
| Scheduled Tasks (§24) | Conditions can reference history events (“if no resolution event in 48 hours”) |
| Workflows (§25) | Wait steps can listen for specific history events to advance |
| Reports (§26) | History events are a data source for timeline reports and audit summaries |
| Templates (§27) | Document sections can include recent history events |
| Rules Engine (§22) | Rule violations produce history events |
Every mutation that matters to the business becomes a history event. Every system that needs to react to business mutations subscribes to history events. This is the event-driven backbone of the platform.
End of Chapter 21.
Next: Chapter 22 — Declarative Rules Engine