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 containing a DateTime lower boundary. AuditLog.Subject.from_schema/2 reads that boundary from a structurally compatible %{from: %DateTime{}} map or a native %Postgrex.Range{lower: %DateTime{}}.

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!()

  with {:ok, subject} <-
         AuditLog.Subject.from_schema(shareholder, type: "shareholder"),
       {:ok, _event} <-
         AuditLog.record(Repo, "shareholder.created", context, subject) do
    shareholder
  else
    {:error, error} -> Repo.rollback(error)
  end
end)

Update

The updated struct contains the post-change version boundary:

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

  with {:ok, subject} <-
         AuditLog.Subject.from_schema(shareholder, type: "shareholder"),
       {:ok, _event} <-
         AuditLog.record(
           Repo,
           "shareholder.address_corrected",
           context,
           subject,
           reason: reason
         ) do
    shareholder
  else
    {:error, error} -> Repo.rollback(error)
  end
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)

  with {:ok, subject} <-
         AuditLog.Subject.from_schema(shareholder, type: "shareholder"),
       {:ok, _event} <-
         AuditLog.record(
           Repo,
           "shareholder.deleted",
           context,
           subject,
           reason: reason
         ) do
    shareholder
  else
    {:error, error} -> Repo.rollback(error)
  end
end)

Load the exact row version

AuditLog stores subject IDs as text. Cast the ID through the application's schema before querying the temporal history:

{:ok, shareholder_id} =
  AuditLog.Subject.cast_id(Shareholder, event.subject_id)

# Query the temporal store with shareholder_id and event.subject_version_at.

Use cast_id/2 for integer and custom Ecto primary-key types. Ecto's UUID and string types accept the stored text directly, but the helper also keeps the query correct if the primary-key type changes.

occurred_at does not identify a row 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.

Each event identifies one subject. When a business action changes several subjects, record one event for each subject and give the events one correlation ID. AuditLog has no event-subject join table.