paper_trail_diff
[!WARNING] Experimental software and AI disclosure: This gem is experimental and may contain incomplete behavior, reconstruction errors, performance problems, or breaking changes. AI-assisted coding tools made significant contributions to its design, implementation, tests, and documentation under maintainer direction. AI involvement is not a substitute for independent review: audit the code and validate it against your own PaperTrail history before relying on it in production, compliance, security, or other high-stakes workflows.
paper_trail_diff adds structured endpoint, checkpoint-timeline, and activity
comparisons to
PaperTrail. It returns immutable
Ruby value objects and hashes rather than formatted text.
PaperTrail is required. Explicit, bounded association-path comparison is available when paper_trail-association_tracking (PT-AT) is installed, loaded, and enabled.
Ruby 3.1 or newer and PaperTrail 16 or 17 are supported.
New to the gem? Run examples/demo.rb to see it work in
about ten seconds — one self-contained file, no application and no migrations,
which fetches what it needs on first run:
ruby demo.rb
Then start with the copyable Quickstart.
Installation
Add the gem to your bundle:
gem "paper_trail_diff"
For association history, also add and configure PT-AT using its migrations:
gem "paper_trail-association_tracking"
PaperTrail.config.track_associations = true
All associated models whose historical state is compared must use
has_paper_trail.
How PaperTrail records state
Almost everything else in this README follows from one property of PaperTrail, so it is worth being precise about it first: a version stores the state that existed before the event that created it. A version is a record of what was overwritten, not of what was written.
Take an article created as "Draft", then updated to "Published", then to
"Final":
v1 v2 v3 (no version)
"create" "update" "update"
| | | |
──────●───────────────●─────────────────●─────────────────●──────▶ time
| | | |
└─── "Draft" ───┘ | |
stored in v2 | |
└─ "Published" ───┘ |
stored in v3 |
└──── "Final" ────┘
only in the table
Each state is stored by the version at the end of the interval it was live for. Three consequences run through the rest of this document:
- A
createversion reifies tonil. Nothing preceded it. Comparing it with a later state reports a structuredrecord_presence_changerather than inventing scalar changes. - The newest state has no version at all. It exists only in the table. A
fully historical result therefore needs a version later than the last change
it should reveal, which is why a
within:window can raiseIncompleteTimeRangeError, and whyactivity_timeline(..., to: article)exists for ending at current state instead. - A change is visible between two boundaries, never "at" one. This is why both timeline APIs return steps rather than events.
The Quickstart walks through the same idea against a real console session, creating the models it uses as it goes.
Which models and schemas work
Anything has_paper_trail tracks, including these, which are covered by the
test suite rather than assumed:
- Single-table inheritance. Results are keyed by the base class, matching
what PaperTrail writes to
item_type. Analyzing aBook < Publicationproduces the key["Publication", "12"], soresults.fetch(["Book", ...])raisesKeyError. Build keys withPaperTrailDiff::Endpoint.identity(record)rather than by hand. - Non-integer primary keys on the tracked model, such as a UUID column.
- A custom version class, via
has_paper_trail versions: { class_name: }. Nothing here referencesPaperTrail::Versiondirectly.
Versions that cannot be ordered
Versions are ordered by created_at, falling back to the id when timestamps
tie. That fallback recovers the real sequence only while ids increase with
insertion. An autoincrement id does; a UUID version id does not.
So two conditions together are unsafe: version ids that are not sequential,
and versions sharing a timestamp — which is ordinary on a MySQL datetime
column, since it stores whole seconds. The order is then unrecoverable, and
rather than report a plausible-looking history in the wrong order the gem
raises PaperTrailDiff::AmbiguousVersionOrderError naming both versions.
diagnose reports the same condition as an error, so a caller can check before
running rather than after a surprise.
Either half alone is fine. Sequential ids order tied timestamps correctly, and distinct timestamps never reach the fallback.
Choosing an entry point
| You need | Call |
|---|---|
| The net difference between two endpoints | compare |
| The same, for many records in one pass | compare_many |
| One step per version of the root record | timeline |
| One step per version of the root or a selected child | activity_timeline |
| A net difference and a timeline from one history pass | analyze |
timeline and activity_timeline differ only in which recorded versions
become boundaries. Given an article with two comment edits between two article
versions:
recorded versions A1 C1 C2 A2 A = Article version
| | | | C = Comment version
────●─────────●───────●─────────●────▶ time
| | | |
timeline └───────── 1 step ──────────┘
both comment changes land inside that one step
activity_timeline └── 1 ────┴── 2 ──┴─── 3 ───┘
each recorded version becomes its own boundary
Both report the same underlying data: a timeline step still contains every
selected association, because it has to describe what changed beneath the root.
They differ in how finely that change is split, and in what the reconstruction
costs. That cost trade-off is covered under
choosing a reconstruction strategy.
Compare two endpoints
compare accepts two explicit endpoints: each may be a PaperTrail version or a
clean, persisted model instance representing current database state. It reports
only their net difference:
diff = PaperTrailDiff.compare(article.versions[1], article.versions[4])
diff.attributes["title"].from # => "Draft"
diff.attributes["title"].to # => "Published"
diff.empty? # => false
diff.to_h
# => {
# record_presence_change: nil,
# attributes: {
# "title" => { from: "Draft", to: "Published" }
# },
# associations: {}
# }
Intermediate edits do not affect compare. If a title changes and later
returns to its original value, endpoint comparison reports no title change.
record_presence_change reports whether the root record existed at each
endpoint, and is nil for the ordinary case where it existed at both. It is
populated when one endpoint reifies to nil — most often a create version,
whose pre-change state is the absence of the record — and it then carries whole
RecordSnapshot values rather than fake scalar changes from nil.
Destroying the root record is not reported this way, and the reason follows
from the pre-change model: the state at a
destroy version is the state immediately before the deletion, so the record
is still present there. A comparison ending at a destroy version reports that
last edit, not the deletion. Deleting a selected child is reported normally,
as a removed member of its parent's collection, and activity_timeline
reports a destroyed root through a
closing removal step.
Pass the record explicitly when the desired endpoint is current state:
diff = PaperTrailDiff.compare(
article.versions.last,
article,
associations: ["comments.replies"]
)
Two version endpoints must be given in chronological order. A transposed pair
produces the inverse diff, which is easy to do by accident and impossible to
detect afterwards because a result carries no direction of its own, so it
raises PaperTrailDiff::ReversedEndpointsError instead. Nothing is lost by
this: the two orders differ only in which side of each change is from.
A current-record endpoint is exempt and may appear on either side, because it is self-evidently the live state and placing it first is a deliberate reverse comparison. Current state is never inferred. The record must be persisted, not destroyed, and free of unsaved attribute changes. The gem reloads it unscoped before normalization, so stale association caches and in-memory edits are not compared. Use a database transaction with an appropriate isolation level when several live association queries must represent one atomic application snapshot.
For collection-level reports, use compare_many to reload current roots and
preload each explicitly selected live association path across the batch. Each
entry has the same endpoints and options as compare; results are returned in
input order as a frozen hash keyed by [item_type, item_id] strings:
Endpoints are still supplied by the caller, so look them up in bulk too — otherwise the per-root queries this API removes come straight back. Two queries resolve the earliest version of every root, whatever the batch size:
orders = Order.where(id: order_ids).to_a
earliest_ids = PaperTrail::Version
.where(item_type: "Order", item_id: orders.map(&:id))
.group(:item_id)
.minimum(:id)
first_versions = PaperTrail::Version
.where(id: earliest_ids.values)
.index_by(&:item_id)
diffs = PaperTrailDiff.compare_many(
orders.map { |order| { from: first_versions.fetch(order.id), to: order } },
associations: [:line_items],
ignore: []
)
diffs.fetch(["Order", orders.first.id.to_s]) # => PaperTrailDiff::Diff
Endpoints may also be given as :first or :last, resolved against the record
the pair's other endpoint names. That replaces the lookup above entirely, and
resolves every root in two queries per model class rather than one per root:
diffs = PaperTrailDiff.compare_many(
orders.map { |order| { from: :first, to: order } },
associations: [:line_items]
)
A symbol carries no identity of its own, so { from: :first, to: :last } raises
rather than guessing. A root with no recorded history resolves to an empty
Diff, matching how the timeline APIs answer the same question.
Root identities must be unique within one call. Historical reconstruction for
ordinary versioned, unscoped association paths is also prepared across the
collection. Paths that require the existing point-in-time PT-AT fallback retain
their per-endpoint behavior and all historical reconstruction retains the same
PaperTrail Association Tracking requirements as compare. Live collection
scopes with per-owner semantics, such as limit, offset, or an owner
argument, are loaded per root; other selected branches remain batched. Callers
should still use an appropriate database transaction when all live queries
must observe one atomic snapshot.
Reuse already-preloaded current endpoints
compare, compare_many, and activity_timeline(..., to: record) reload
current endpoints by default. A caller that already owns a consistent, fully
preloaded graph may opt out. Each endpoint is loaded once per call, so a batch
that resolves every root up front and then reads one of them again pays
nothing the second time, and every root in one result reflects the same instant.
The option is not accepted by timeline, analyze, or analyze_many. Those
are bounded by versions and read live state only under
close_on: :current, which always reloads:
orders = Order.where(id: order_ids).preload(line_items: :product).to_a
# `first_versions` is the same bulk endpoint lookup shown above.
diffs = PaperTrailDiff.compare_many(
orders.map do |order|
{ from: first_versions.fetch(order.id), to: order }
end,
associations: ["line_items.product"],
reload_live_endpoints: false
)
With reload_live_endpoints: false, scalar values and association targets come
from the supplied instances. Every requested association path must already be
loaded; otherwise PaperTrailDiff::UnloadedAssociationError is raised before
normalization instead of silently issuing an N+1 query. The caller is
responsible for the consistency and freshness of that in-memory graph.
Runtime performance diagnostics
The gem never prints or logs automatically. It emits these
ActiveSupport::Notifications events so applications can choose their logger
and formatting:
compare.paper_trail_diffcompare_many.paper_trail_diffactivity_timeline.paper_trail_diffload_live_endpoints.paper_trail_diffprepare_history.paper_trail_diff
ActiveSupport::Notifications.subscribe(/\.paper_trail_diff\z/) do |name, start, finish, _id, payload|
Rails.logger.debug(
event: name,
duration_ms: ((finish - start) * 1_000).round(1),
**payload
)
end
The payloads contain counts, model names, association paths, and reload mode,
not endpoint objects or record attributes. The activity-timeline event wraps
the complete call and includes step_count, so its notification duration is
the user-visible runtime. To inspect the exact SQL generated by one report,
scope a standard sql.active_record subscriber around it:
callback = proc do |_name, _start, _finish, _id, payload|
Rails.logger.debug(payload[:sql]) unless payload[:cached] || payload[:name] == "SCHEMA"
end
ActiveSupport::Notifications.subscribed(callback, "sql.active_record") do
PaperTrailDiff.compare_many(comparisons, associations: [:line_items])
end
Analyze many records over one window
analyze_many answers "what changed for these records during this period" for a
whole collection, selecting every root's versions and preparing their selected
association history once for the batch:
results = PaperTrailDiff.analyze_many(
Order.where(status: "open").to_a,
within: Time.zone.parse("2026-08-01")...Time.zone.parse("2026-09-01"),
associations: [:line_items]
)
results.fetch(["Order", order.id.to_s]).diff # net change across the window
results.fetch(["Order", order.id.to_s]).timeline # its checkpoint steps
Results are a frozen hash keyed by [item_type, item_id] strings, and each
value is the same Analysis that analyze returns for one record. A root with
no versions inside the window gets an empty Analysis rather than raising, so a
listing page needs no special case. Root identities must be unique.
Reporting on a subset of root mutations
version_scope: narrows which root versions count as selected mutations, so a
checkpoint timeline reports only some of them:
user_edits = ->(scope) { scope.where.not(whodunnit: nil) }
PaperTrailDiff.analyze_many(articles, within: window, version_scope: user_edits)
PaperTrailDiff.timeline(article, within: window, version_scope: user_edits)
The hook receives the version relation for the range and returns a narrowed one.
It is accepted by timeline, activity_timeline, analyze, and
analyze_many, with any range form.
It sees root versions only. Someone who edited only comments or tags has no root versions to select, so filtering to them yields an empty result even though they changed plenty. For "what did this person change?", reach for one person's changes instead — this hook answers the different question of which root checkpoints a timeline reports.
It filters selected mutations only. Versions the filter excludes are still loaded, because a version records the state before its own event: without the one that follows a selected change, whatever that change produced cannot be shown at all. Those extra versions are reconstruction context, so their own changes are never attributed to a selected mutation.
Each selected mutation is bounded by the version that immediately followed it, not by the next selected one. So each step's diff is exactly what that mutation did, and it reads the same however many excluded changes happen to follow it:
versions system → alice → system → bob → system
reported alice: what alice changed bob: what bob changed
Given a user edit followed by a system edit, filtering to user changes yields
one step running from the user version to the system version, whose diff is
exactly the user's change. A selected mutation that nothing follows yet is not
reported, since no version records the state it produced — the same blind spot
an unfiltered timeline has at its to: boundary. A selected destruction is
the exception: nothing can follow one, but the absence it leaves is what it
produced, so activity_timeline still closes on it. A root with no selected
mutation reports an empty Analysis rather than raising.
The hook applies to root versions only. Under activity: true the filter
decides where the span starts and ends, and timeline within that span reports
only selected mutations, but activity_timeline still lists every boundary
inside it — dropping one would fold the change it carried into a neighbouring
step and credit it to whoever made that one.
Omit within: to analyze each root's whole recorded history instead, which is
the batched equivalent of analyze(record, from: :first, to: :last). Explicit
version endpoints are not accepted, because a single pair cannot mean the same
thing for every root.
Query cost is flat in the number of roots for the diff and timeline views:
selecting versions and preparing history are both shared across the batch. On a
twenty-root batch that is 7 queries against 120 for the same work done one
record at a time. Passing activity: true also works and returns the same
results, but discovering descendant events is inherently per-root, so that view
batches far less.
Roots are supplied as live records, so a root deleted inside the window cannot
be included; use activity_timeline for a history that ends in a deletion.
Reporting on one person's changes
An activity timeline already carries this. Every boundary in the span becomes a
step, and a step's diff is exactly what the event at its from_boundary did, so
that boundary's whodunnit is who made the change:
steps = PaperTrailDiff.activity_timeline(
article, within: window, associations: %i[comments tags]
)
attributed = steps.reject(&:empty?)
by_priya = attributed.select { |step| step.from_boundary.whodunnit == "Priya Shah" }
by_priya.first.from_boundary.whodunnit # "Priya Shah"
by_priya.first.diff # exactly what that one event changed
= attributed.group_by { |step| step.from_boundary.whodunnit }
.transform_values(&:length)
This covers descendants, which is what version_scope: cannot do. On one demo
history a contributor who only ever touched comments and authorships has four
attributed activity steps and zero root steps.
Filter in Ruby rather than asking the gem to select fewer versions. Attribution is only correct because every boundary is reconstructed: skip one and the snapshot carried into the next step is a state the record had already moved past, so the change lands on the wrong person. There is nothing to save by filtering earlier — the work is the reconstruction, not the comparison — and a predicate can say things a version relation cannot, such as several people at once or "anyone but the importer".
reject(&:empty?) first, because a boundary that changed nothing you selected
still produces a step. The final event in the range has no boundary after it, so
what it produced is not shown — the same blind spot to: has everywhere else.
For "which of these records did someone touch?" across a listing, note that
analyze_many batches the diff and timeline views but not descendant discovery.
Querying versions by whodunnit and mapping them back to roots yourself will be
much cheaper than an activity pass per record.
Build a root-checkpoint timeline
timeline accepts two version objects from the supplied record's history, or
the symbols :first and :last when the range is simply the record's whole
recorded history:
steps = PaperTrailDiff.timeline(article, from: :first, to: :last)
:first and :last are resolved by the gem, independently of the order the
versions association happens to use, so a caller never has to know it is
sorted. They work anywhere a version does, including mixed with an explicit
one (from: :first, to: some_version) and on activity_timeline and
analyze. A record with no versions has no boundaries to resolve; that is an
empty history rather than a bad request, so the result is an empty timeline
instead of an error — which is usually what an index page wants.
Combine from: :first with to: article for the fullest activity view of a
live record: the whole recorded history, ending at current state. See
activity timelines for why that end differs from
to: :last.
The range is inclusive, must be chronological, and produces one Step for each
adjacent pair:
steps = PaperTrailDiff.timeline(
article,
from: article.versions[1],
to: article.versions[4]
)
steps.first.from_version # the original PaperTrail version
steps.first.to_version # the next PaperTrail version
steps.first.from_boundary # immutable presentation metadata
steps.first.to_boundary
steps.first.diff # a PaperTrailDiff::Diff
steps.first.empty? # delegates to the diff
steps.first.to_h
# => { from_version_id: 2, to_version_id: 3, diff: { ... } }
Both timeline types expose from_boundary, to_boundary, diff, and
empty?. A historical boundary has event, whodunnit, record,
recorded_at, version?, current?, and destroyed? readers. Checkpoint Step objects
also retain their original from_version and to_version for callers that
need custom PaperTrail metadata. Existing Step#to_h and
ActivityBoundary#to_h shapes remain unchanged; use the readers for the new
metadata.
Every version boundary remains in the result, even when its diff is empty after ignored fields are removed. Equal boundaries return a frozen empty array.
This is deliberately a root-record timeline. Several child changes between two root versions appear together in the same step.
Build an activity timeline
activity_timeline merges versions for the root and explicitly selected
descendants. Child changes can therefore form separate steps instead of waiting
to be aggregated at the next root checkpoint:
steps = PaperTrailDiff.activity_timeline(
article,
from: article.versions[1],
to: article.versions[4],
associations: ["comments.replies", :author]
)
steps.reject(&:empty?)
Pass the record explicitly as to: to include current state without creating a
final root checkpoint. This is not the same as to: :last: descendant
mutations recorded after the record's final root version fall outside a
version-bounded range, so only a live end reports them.
steps = PaperTrailDiff.activity_timeline(
article,
from: article.versions[1],
to: article,
associations: ["comments.replies", :author]
)
The result is a frozen array of ActivityStep objects. Each step has immutable
from_boundary, to_boundary, and diff readers. Historical boundaries are
kind: :version; an explicitly requested live endpoint is kind: :current:
steps.last.to_boundary.to_h
# => {
# kind: :current,
# version_id: nil,
# item_type: "Article",
# item_id: 42,
# recorded_at: 2026-08-08 12:00:00 UTC
# }
A historical boundary may belong to the root or a selected descendant model.
PaperTrail versions describe pre-change state, so a mutation becomes visible
between its version boundary and the next historical or explicit current
boundary. Passing to: article is what removes the need to touch the parent
after an ordinary versioned child mutation; current state is still never
implicit.
Reading the state behind a step
A step's diff carries what changed. A renderer often needs what did not — to
say whose comment was edited, it needs the comment's author, which the diff has
no reason to mention. snapshots: true retains the reconstructed states each
step was compared between:
steps = PaperTrailDiff.activity_timeline(
article, from: :first, to: article, associations: [:comments], snapshots: true
)
step = steps.reject(&:empty?).last
step.from_snapshot.associations["comments"].records.first.attributes["author"]
The gem already builds these states to compute each diff and otherwise discards them, so asking for them costs no extra queries. Reconstructing them yourself from the version table means reimplementing boundary ordering, and getting it wrong is quiet rather than loud.
They are off by default because each one holds the whole selected graph, and
retaining one per step keeps the entire timeline's graph alive. A step whose
from_boundary is a create version has a nil from_snapshot: the record
did not exist yet, which is the answer rather than a missing one.
analyze(activity: true, snapshots: true) populates the same fields on the
activity_timeline it returns.
Closing a destroyed root
A destroy version is the one boundary whose following state needs no later
version: the event itself says the record is gone. When an activity timeline
ends at the root's own destroy version, it therefore closes with one more
step, from that version to a kind: :destroyed boundary, whose diff is a
record_presence_change from the record's final state to nil:
steps = PaperTrailDiff.activity_timeline(
article,
from: article.versions.first,
to: article.versions.last # a destroy version
)
removal = steps.last
removal.to_boundary.destroyed? # => true
removal.to_boundary.kind # => :destroyed
removal.diff.record_presence_change.from # the state it was deleted in
removal.diff.record_presence_change.to # => nil
The removal step's from_boundary is the ordinary kind: :version boundary
for the same destroy version, since that boundary still holds the record.
Boundaries therefore have three kinds — :version, :current, and
:destroyed — so a consumer that branches on version? alone should also
handle destroyed?.
analyze(activity: true) reports the same closing step in its
activity_timeline. Its diff and timeline keep their compare and
timeline semantics and do not report the deletion.
A within: window behaves the same way. A window whose last selected mutation
is the root's destruction needs no later root version, because none can ever
exist, so it closes on the removal instead of raising
IncompleteTimeRangeError. When the destruction falls outside the window it
remains ordinary reconstruction context and is not reported as a selected
mutation. The same relaxation lets the plain timeline accept such a window,
though it still reports only the edits.
Live-ended HABTM activity is rejected with
PaperTrailDiff::UnsupportedLiveActivityError. HABTM join rows have no model
versions, so the gem cannot reliably split their membership mutations into
intermediate events without transaction-backed owner checkpoints. Historical
HABTM activity and compare(version, article, associations: [:tags]) remain
supported where the historical endpoint has usable PT-AT metadata.
Both timeline APIs preserve empty boundaries. A display may use
steps.reject(&:empty?), while audit-oriented callers can retain every
recorded boundary.
Select mutations by time
timeline, activity_timeline, and analyze also accept a finite time range
through within: instead of explicit from: and to: versions. Half-open
ranges are recommended for adjacent reporting windows:
window = Time.zone.parse("2026-08-01")...Time.zone.parse("2026-09-01")
steps = PaperTrailDiff.timeline(article, within: window)
activity_steps = PaperTrailDiff.activity_timeline(
article,
within: window,
associations: ["comments.replies", :author]
)
analysis = PaperTrailDiff.analyze(
article,
within: window,
associations: ["comments.replies"],
activity: true
)
The range selects mutations by the timestamp of their source PaperTrail
version. Ruby's inclusive (..) and exclusive (...) end semantics are
honored. A returned step's to_boundary may be after the range: because a
PaperTrail version is a pre-change snapshot, the gem needs one later root
version to reveal the final selected mutation. That version is reconstruction
context, not an additional selected mutation.
If the window contains a relevant mutation but no later root version exists,
the call raises PaperTrailDiff::IncompleteTimeRangeError. The gem does not
silently substitute current database state; pass
close_on: :current to ask for it, or create a root
checkpoint after the reporting window. A root-only window with no selected
mutation returns a frozen empty timeline. The one exception is a window that
closes on the root's own destruction, which no later version can ever follow;
see closing a destroyed root.
Reporting up to now
A window whose end is the present has nothing recorded after its final
mutation, so by default it raises. close_on: :current closes it on the live
record instead:
PaperTrailDiff.analyze_many(
articles, within: month_start..Time.current, close_on: :current
)
It is accepted by timeline, activity_timeline, analyze, and
analyze_many, and only alongside within: — a range whose endpoints you gave
explicitly already says where it ends. The closing step's to_boundary.kind is
:current and its to_version is nil, so read the boundary rather than the
version on the last step. Under activity: true the window runs to the instant
state is captured rather than to the last root version, so a descendant that
moved after that version is still reported.
Two things follow from closing on live state. Current state includes changes
PaperTrail never recorded — an update_columns write shows up in that final
step, attributed to the version that precedes it, because nothing else records
who made it. And a destroyed root has no current state to close on, so it keeps
closing on its own destruction as it already did.
Time ranges and explicit from:/to: endpoints are mutually exclusive.
Malformed, open-ended, or reversed ranges raise
PaperTrailDiff::InvalidTimeRangeError. With activity_timeline, selected
descendant versions are included even when no root mutation occurred inside
the window; the later root checkpoint is still required so PT-AT can
reconstruct the enclosing graph and determine whether descendant activity is
present. The ordinary timeline remains a root-only checkpoint timeline.
Build an endpoint and timeline together
When a caller needs the net endpoint diff and root timeline, analyze
normalizes each selected version once. Pass activity: true when the same view
also needs descendant-aware activity. The endpoint diff and root timeline are
then derived from the same activity snapshot pass instead of reconstructing the
root history separately:
analysis = PaperTrailDiff.analyze(
article,
from: article.versions[1],
to: article.versions[4],
associations: ["comments.replies"],
activity: true
)
analysis.diff
analysis.timeline
analysis.activity_timeline
Without activity: true, analysis.activity_timeline is nil and no activity
work is performed. analyze accepts explicit historical versions or
within:, but not a current-record endpoint; use the standalone
activity_timeline(..., to: article) API when the final boundary must be live.
Choosing between timeline and analyze(activity: true)
analysis.timeline and timeline return the same root-checkpoint steps for the
same range. They are two reconstruction strategies for one result, not two
levels of detail: a timeline step covers only root version boundaries, but
each of its snapshots still contains every selected association, because a step
must report what changed underneath the root as well.
The one behavioural difference is at the edge of a within: window. Selected
descendants can move inside a window that contains no root version at all, so
the activity form requires a root boundary it can reconstruct from and raises
IncompleteTimeRangeError when there is none. timeline has no activity view
to anchor and returns no steps for that window.
The strategies differ in what that costs:
timelinereconstructs the whole selected graph independently at every root boundary, so it costs roughly root versions x selected graph size. It is insensitive to how much descendant activity happened in between.analyze(activity: true)reconstructs once and then advances that snapshot through each recorded mutation, so it costs roughly one reconstruction + total events. It is insensitive to how wide the selected graph is.
Neither dominates. Reconstructing once per checkpoint wins when a few root
versions span very heavy descendant churn; advancing incrementally wins when
the selected graph is wide and descendant activity is comparable to root
activity. As a rule of thumb, prefer analyze(activity: true) when selected
associations are wide, and timeline when descendant events greatly outnumber
root versions. Measure with ActiveSupport::Notifications on a representative
history rather than a seeded example if the choice matters.
timeline, activity_timeline, and both forms of analyze prepare the selected
historical range once. The loader walks only the explicit association paths and
builds an immutable temporal index of scalar states, relationship candidates,
HABTM transaction snapshots, and live fallbacks. Direct relationships,
has_many :through with a belongs_to source, and transaction-backed HABTM
membership are resolved from that index. Endpoint-only compare retains the
lighter two-point reconstruction path.
Activity reconstruction then carries immutable snapshots forward between boundaries. Isolated root and descendant events with usable serialized changes advance only the affected immutable nodes. Events sharing a PT-AT transaction refresh their combined branches atomically. Scoped associations, unversioned targets, composite relationship keys, collection-source through associations, and other unsupported shapes fall back to the ordinary PT-AT point reifier on a per-reflection basis. This hybrid path preserves existing reconstruction behavior while avoiding repeated association queries for supported paths.
For supported collection events, adjacent snapshots share an immutable
identity-position index and carry a one-step transition hint. Diffing therefore
visits only the changed member instead of hashing the whole collection again.
Prepared scalar history also exposes the predecessor and successor attributes
for isolated update events, allowing activity reconstruction to update the
immutable snapshot directly instead of reifying and deserializing the same
PaperTrail event again. Compatible scalar payloads are decoded into prepared
state without first constructing a disposable Active Record object. Encrypted,
schema-mismatched, missing, ambiguous, or identity-changing states fall back to
the ordinary event reifier.
For a direct nested has_many such as comments.replies, the child's foreign
key also locates its parent snapshot directly rather than walking every
comment. Membership changes and ambiguous or unsupported relationship shapes
retain the general comparator and traversal fallback.
Activity event loading is bounded to the selected range. Association identity discovery retains one indexed checkpoint for members present at the starting boundary, then considers later association activity and current members; it no longer materializes every pre-range association row. Because a PaperTrail version is a pre-change snapshot, the state at a range's final boundary can live only in the next version after it, so prepared scalar state retains one trailing version per selected identity. It does not retain the rest of the history recorded after the range, so a short range early in a long history costs the same as the same range in a short one. Keep requested paths and ranges intentional. An activity timeline must also emit a diff for every selected event. Repeated events within one wide collection still copy the frozen records array when producing each immutable snapshot, so they can do pointer-copying work proportional to the number of events times the collection width even when SQL and Ruby-level comparison work stay linear. Bound or paginate unusually wide activity ranges in latency-sensitive requests.
For large histories, applications should give the database a matching composite index. A typical PaperTrail installation can add one without making it a requirement of this gem:
add_index :versions, %i[item_type item_id created_at id],
name: "index_versions_on_item_and_timeline"
This index is especially useful for repeated within: queries because root
selection is constrained by item_type, item_id, and created_at, then
ordered deterministically by timestamp and version ID.
PT-AT's normal index beginning with foreign_key_name, foreign_key_id, and
foreign_type should also be retained on version_associations.
Ignore noise fields
The comparison APIs ignore updated_at by default. ignore: replaces that default and
accepts string or symbol names. The array form applies to the root and every
selected association:
PaperTrailDiff.compare(from, to, ignore: %i[updated_at lock_version])
PaperTrailDiff.compare(from, to, ignore: []) # compare every scalar field
For exact path control, pass all: plus paths:. $ identifies the root:
PaperTrailDiff.compare(
from,
to,
associations: ["comments.replies"],
ignore: {
all: [:updated_at],
paths: {
"$" => [:lock_version],
"comments.replies" => [:delivery_state]
}
}
)
Hash rules replace the default just like an array. all: applies everywhere;
each path entry applies only at that exact path, not its descendants. Ignore
paths must be $ or one of the selected or implicitly selected association
paths.
Primary keys are always represented as record identity rather than scalar attributes.
Compare associations
Pass explicit names or dot-separated paths. Paths are finite traversal plans, not a request to recursively inspect the whole object graph. Ancestors are selected implicitly:
diff = PaperTrailDiff.compare(
from,
to,
associations: [:author, :profile, "comments.replies.author"]
)
diff.associations["author"].relationship
# => #<PaperTrailDiff::ValueChange ...> # belongs_to replacement
comments = diff.associations["comments"]
comments.added # full RecordSnapshot objects
comments.removed # full RecordSnapshot objects
comments.changed # RecordChange objects for stable identities
replies = comments.changed.first.associations["replies"]
replies.added
replies.removed
replies.changed
For belongs_to and has_one, replacing the related identity is a
relationship change. Updating attributes on the same related identity is a
changed record. For has_many and has_and_belongs_to_many, membership is
split into added and removed; shared identities with scalar changes appear
in changed. The result preserves the reflected macro in kind.
Both Rails many-to-many forms are supported. has_many :through uses PT-AT's
history for the versioned join model. HABTM reports membership and target
attribute changes, but it cannot report join attributes because HABTM has no
join model.
The same structure repeats at every selected depth. RecordChange#associations
contains nested changes for a stable parent identity. Added and removed record
snapshots retain their selected subtrees so nested state is not discarded.
Selecting a belongs_to removes its foreign-key (and polymorphic type) column
from scalar changes at that path. Without association selection, that column
remains a normal scalar attribute. A direct has_one or has_many edge also
removes its incoming foreign key from the child snapshot because membership is
already represented structurally. Join-model attributes for has_many :through
remain available. Cyclic model relationships are safe because only the finite
paths supplied by the caller are traversed.
Requesting associations for any historical endpoint without loaded and enabled
PT-AT raises PaperTrailDiff::AssociationTrackingUnavailableError. A comparison
whose endpoints are both live records can normalize associations without PT-AT,
though it will normally be empty because both records reload the same current
database state. Unknown names and unsupported macros raise explicit
PaperTrailDiff::Error subclasses.
Malformed public options raise PaperTrailDiff::ConfigurationError, also under
that base error.
Traverse result trees
The nested tree remains the canonical, lossless result. For renderers,
counters, exports, and notifications, Diff#each_change provides a
deterministic depth-first stream of semantic changes:
diff.each_change do |entry|
entry.kind # :attribute_changed, :record_added, ...
entry.association_path # ["comments", "replies"]
entry.record_path # frozen RecordReference objects
entry.association_kind # :has_many, :belongs_to, ...
entry.attribute # "body" for an attribute entry
entry.value # ValueChange, RecordChange, or RecordSnapshot
end
counts = diff.each_change.map(&:kind).tally
record_changed entries are emitted before that record's attribute and nested
association changes, allowing an application to count both changed records and
changed fields. Singular relationship operations are classified separately as
relationship_added, relationship_removed, or relationship_replaced.
Root create/delete transitions remain record_presence_changed.
Added, removed, and replaced records carry complete bounded snapshots. Use
each_entry when a renderer also needs that nested state without writing a
second snapshot walker:
diff.each_entry do |entry|
next unless entry.included_state?
entry.kind # :record_included, :attribute_included, :association_included
entry.state # :before or :after
entry.context # :included_state
end
An included record is historical context, not evidence that the nested record
changed independently. each_change therefore omits included-state entries.
Both methods return an Enumerator when no block is supplied and return the
diff when called with a block. Entries and their paths are immutable and have
deterministic to_h output. The root location uses empty association and record
paths; descendant record_path values contain only the explicitly traversed
descendant identities.
Traversal is path-preserving and does not deduplicate. If the same record is reachable through two selected paths, it appears at both locations so the consumer can choose whether path identity or record identity controls counting. Walking a result never performs database access and never discovers additional associations.
Discover and diagnose associations
Configuration UIs can use bounded public reflection instead of duplicating the gem's macro and cycle logic:
PaperTrailDiff.supported_association_macros
PaperTrailDiff.association_paths(Article, max_depth: 3).map(&:to_h)
Descriptors include path, kind, target_type, through, polymorphic, and
cycle. Cycles are marked and not descended; PaperTrail's infrastructure
versions association is excluded.
Before relying on association history, inspect known setup hazards:
report = PaperTrailDiff.diagnose(
from_version,
to_version,
associations: [:tags, "comments.replies"]
)
report.ok?
report.errors.map(&:code)
report.warnings.map(&:code)
ok? means no errors were found among the checks that ran, not that every
possible hazard was ruled out. Association checks need associations: to have
something to inspect; without it only the version-order check runs, which is
why that one runs whether or not associations are selected — an unorderable
history corrupts a scalar timeline just as thoroughly.
Diagnostics are read-only guidance, not proof that arbitrary old data is
complete. HABTM endpoints without transaction-backed association snapshots fail
loudly with PaperTrailDiff::IncompleteAssociationHistoryError during normal
comparison as well.
Recommended checkpoint recipe
For PT-AT history—especially HABTM—use transaction-backed checkpoints, disable PaperTrail's version timestamp synchronization on the root, and checkpoint a fresh record instance:
class Article < ApplicationRecord
has_paper_trail synchronize_version_creation_timestamp: false
end
def checkpoint_article(article_id)
Article.transaction do
Article.find(article_id).paper_trail.save_with_version
end
end
A root checkpoint after a batch supplies an immutable historical final state.
The ordinary timeline aggregates that batch; activity_timeline uses recorded
descendant versions to split it into activity boundaries. For an interactive
view, activity_timeline(..., to: article) can instead terminate explicitly at
current state without touching the root. Prefer checkpoints for reproducible
audits and HABTM, and prefer an explicit join model with has_many :through
when join attributes or join mutations must appear as first-class history.
Result objects
Every level of a result separates the same two kinds of change in the same way:
| a record appears or disappears | a record stays and its fields change | |
|---|---|---|
| the root record | record_presence_change |
attributes, associations |
has_many, HABTM |
added, removed |
changed |
belongs_to, has_one |
relationship |
changed |
The left column carries whole RecordSnapshot values; the right column carries
field-level ValueChange deltas. That split is deliberate. A record that has
just appeared has no previous value for any of its fields, so reporting one
nil to value change per attribute would both add noise and blur the
difference between "this field was edited" and "this record did not exist".
The consequence is that reading a result tree directly means branching on which
column applies: a created record's state is under
record_presence_change.to.attributes, an edited record's is under
attributes. Consumers that would rather not branch should use
each_entry, which flattens both into one stream —
an edit arrives as attribute_changed carrying a ValueChange, and a created
record's fields arrive as attribute_included entries carrying values with
state: :after.
The public result types are:
PaperTrailDiff::DiffPaperTrailDiff::StepPaperTrailDiff::ActivityStepPaperTrailDiff::ActivityBoundaryPaperTrailDiff::AnalysisPaperTrailDiff::ValueChangePaperTrailDiff::TraversalEntryPaperTrailDiff::RecordReferencePaperTrailDiff::RecordSnapshotPaperTrailDiff::RecordChangePaperTrailDiff::ToOneAssociationDiffPaperTrailDiff::CollectionAssociationDiffPaperTrailDiff::AssociationDescriptorPaperTrailDiff::DiagnosticReportPaperTrailDiff::DiagnosticIssue
Analysis also exposes the reconstructed states its diff was taken between, as
from_snapshot and to_snapshot. A report that renders unchanged columns needs
the whole final state, not only what moved, and these are the states the gem
already reconstructed:
analysis.to_snapshot.attributes # every selected scalar, changed or not
analysis.to_snapshot.associations # the selected association tree
Either is nil when that endpoint has no reconstructable state — most often a
from_snapshot at a create boundary, whose pre-change state is the absence of
the record. Following the precedent set by Step, Analysis#to_h is unchanged;
serialize to_snapshot.to_h when a serialized form is wanted.
They expose readers, are frozen after construction, and provide deterministic
to_h output. Collection results are ordered by record identity: by type, then
naturally within one id type, so numeric ids sort 2 before 10. Mixed or
unusual id types still order totally rather than raising.
Structural hash keys are symbols; attribute and association
names are strings. Attribute values retain their Ruby types. RecordChange#record
is a RecordReference with type and id readers. TraversalEntry#record and
#association return the final components of their corresponding paths. Step
itself is frozen but intentionally retains the original, potentially mutable
PaperTrail version objects for metadata access; its new boundary readers provide
an immutable presentation interface, while Step#to_h continues to emit only
version IDs. ActivityStep retains only immutable boundary metadata and
serializes as { from: ..., to: ..., diff: ... }.
Historical correctness and limitations
The output is only as correct and complete as the historical state PaperTrail and PT-AT can reconstruct. In particular:
- attributes skipped by PaperTrail, deleted versions, and changes made while versioning was disabled cannot be recovered;
- model/schema or serializer changes can affect reification of old versions;
- comparison and activity APIs add live state only when the caller explicitly passes a persisted record endpoint; the adapter reloads that record, but multiple queries are not automatically one database-isolated snapshot;
- PT-AT requires its schema, callbacks, versioned child models, and transaction metadata; callback-skipping writes may not be reconstructable;
- HABTM membership is limited to the join snapshots PT-AT recorded in
version_associations; historical target attributes require versioned target models, otherwise PT-AT may return live target state; compare,timeline, andanalyze's endpoint diff do not report the root record's destruction, because the state recorded at adestroyversion is the state before the deletion;activity_timelinecloses on a:destroyedboundary instead, and a selected child's removal is reported by its parent;timelineandanalyzeare bounded by root versions;activity_timelineadds recorded descendant boundaries and may terminate at an explicitly passed current record, while a fully historical result still requires a later root version because PaperTrail stores pre-change snapshots;- HABTM join-table mutations do not have their own model versions and therefore cannot become standalone activity boundaries; live-ended HABTM activity is rejected, and a versioned join model should be used when that activity matters;
- activity discovery uses PT-AT association rows and cannot recover descendant events whose relevant callbacks or association metadata were never recorded;
- PT-AT has documented edge cases, especially around some
has_onehistories;paper_trail_diffrespects PT-AT's configured reification error behavior; - deeper paths require more historical reconstruction and database work, so callers should select only the branches they need;
- no implicit or unbounded recursive association traversal is performed.
Review the PaperTrail version semantics and PT-AT limitations before relying on reconstructed history for restoration or compliance work.
Development
The project uses mise for its pinned development tools:
mise install
mise exec -- bundle install
mise exec -- bundle exec rake
mise exec -- bundle exec rake build
mise exec -- act push -j quality --matrix ruby:4.0 --matrix paper_trail:17
The default Rake task runs core specs without PT-AT loaded, association specs in
a separate process, RuboCop, generated-signature verification, and Steep.
Selected Ruby blocks in this README and the quickstart are executed in those
same isolated test sessions; the <!-- executable:... --> marker opts a block
into the appropriate stateful session.
Inline #: annotations in lib/ generate the committed RBS files under
sig/generated/.
Release tags matching v* publish through RubyGems trusted publishing. Before
tagging, replace Unreleased in the changelog with the release date, run the
full CI workflow, and configure the repository's release environment as a
trusted publisher on RubyGems. Do not store a long-lived RubyGems API key in
the repository.
License
MIT