Chapter 21 · Volume III — The Intelligence Layer

Declarative History of Change

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
AudienceDevelopers, DBAs, operationsEnd users, compliance officers, business stakeholders
FormatJSON field diffs: { "status": { "from": "draft", "to": "published" } }Human-readable narratives: “Article published by Alice”
GranularityEvery field change on every operationBusiness-significant events only (domain author declares which)
Configurationfeatures.audit: true + optional audit: blockhistory: block with explicit event declarations
PurposeDebugging, forensic reconstruction, data recoveryActivity 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}_history table
  • A GET /:id/history endpoint returning a paginated timeline for one record
  • A GET /{resource}/history endpoint 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

PropertyTypeRequiredDefaultDescription
onstringyesTrigger type: create, update, delete, restore, transition, fieldChange
transitionstringif on: transitionState machine transition name
fieldstringif on: fieldChangeField name to watch for changes
fieldsstring[]noFor on: update — fire only when these fields change
capturestring[]no[]Field names to snapshot in the event data
labelstringyesHuman-readable label with {fieldName} interpolation
detailstringno""Extended description with {fieldName} interpolation
iconstringno""Icon reference for UI rendering (e.g., lucide:check-circle)
severitystringno"info"info, success, warning, error
rolesstring[]no[]Restrict event visibility to these roles (empty = visible to all)
conditionstringno""AEL expression — event fires only when true
snapshotbooleannofalseWhen 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:

  1. Captured fields — values from the capture array
  2. Automatic capturesfrom and to states for transitions; old and new values for field changes
  3. 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:

SeverityColor ConventionUse For
infoBlue/grayRoutine operations: created, updated, assigned
successGreenPositive outcomes: approved, published, resolved
warningAmber/orangeAttention needed: reopened, blocked, escalated
errorRedProblems: 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:

TriggerAuto-CapturedDescription
on: transitionfrom, toPrevious and new state values
on: fieldChangepreviousValue, newValuePrevious and new field values
All triggersactorId, actorNameThe 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

ParameterTypeDescription
pageintegerPage number (default 1)
limitintegerItems per page (default from display.defaultLimit, max from display.maxLimit)
eventstringFilter by event name: ?event=resolved
severitystringFilter by severity: ?severity=error
afterdatetimeEvents after this timestamp
beforedatetimeEvents before this timestamp
actorIduuidEvents 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:

ParameterTypeDescription
recordIduuidFilter 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
KeyTypeDefaultDescription
endpointbooleantrueExpose the GET /:id/history endpoint
defaultLimitinteger50Default page size for history queries
maxLimitinteger200Maximum page size
orderstring"desc"Default sort: "desc" (newest first) or "asc" (oldest first)
includeActorbooleantrueInclude actorId and actorName in every event
groupBystring""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:

FeatureHow 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