A temporal-table library stores database row states over system time. AuditLog stores the actor and intent behind a business action. Keep those facts separate and link an event to one exact row version.

AuditLog has no compile-time dependency on a temporal-table package. A compatible Ecto schema has a sys_period field whose loaded value matches %{from: %DateTime{}}. AuditLog.Subject.from_schema!/2 copies that from boundary into the event's subject_version_at.

Insert

Build the subject from the struct returned by the repository write. The temporal integration must load sys_period after writes so the returned struct contains the new version's lower boundary.

Repo.transaction(fn ->
  shareholder =
    %Shareholder{}
    |> Shareholder.changeset(attrs)
    |> Repo.insert!()

  subject =
    AuditLog.Subject.from_schema!(shareholder,
      type: "shareholder"
    )

  AuditLog.record!(
    Repo,
    context,
    "shareholder.created",
    subject
  )

  shareholder
end)

Update

The updated struct contains the post-change version boundary:

Repo.transaction(fn ->
  shareholder =
    shareholder
    |> Shareholder.changeset(attrs)
    |> Repo.update!()

  subject =
    AuditLog.Subject.from_schema!(shareholder,
      type: "shareholder"
    )

  AuditLog.record!(
    Repo,
    context,
    "shareholder.address_corrected",
    subject,
    reason: reason
  )

  shareholder
end)

Delete

Build the subject from the deleted struct. With a compatible temporal integration, it retains the outgoing version's lower boundary, which identifies the row in history:

Repo.transaction(fn ->
  shareholder = Repo.delete!(shareholder)

  subject =
    AuditLog.Subject.from_schema!(shareholder,
      type: "shareholder"
    )

  AuditLog.record!(
    Repo,
    context,
    "shareholder.deleted",
    subject,
    reason: reason
  )

  shareholder
end)

Load the exact version

Subject IDs are stored as text. Cast the ID through the application's schema before querying the temporal history:

shareholder_id =
  AuditLog.Subject.cast_id!(
    Shareholder,
    event.subject_id
  )

# Pass shareholder_id and event.subject_version_at to the temporal store's
# exact-version query.

The cast matters for integer and custom Ecto primary-key types. UUID and string schemas often accept the stored value directly, but the helper keeps the query correct if the key type changes.

Why occurred_at is not the version

subject_version_at is the lower boundary of one exact temporal version. occurred_at is the audit event transaction's start time. Timestamp proximity does not make them interchangeable:

  • An update boundary resolves to the post-change version.
  • A deleted row's outgoing boundary resolves to its history version.
  • transaction_timestamp() records transaction start, not commit.

Half-open temporal periods mean that looking up a deleted row at the audit event's occurrence time can return no row or the wrong row. When the event has a version boundary, use event.subject_version_at.

Record one event per affected subject when a business action changes several records. Give the events one correlation ID. This version deliberately has no event-subject join table.