Skip to content

feat(audit_trail): record and expose record history in the agent, gated on an audit database - #320

Open
bexchauveto wants to merge 23 commits into
mainfrom
feat/audit-trail-plugin
Open

feat(audit_trail): record and expose record history in the agent, gated on an audit database#320
bexchauveto wants to merge 23 commits into
mainfrom
feat/audit-trail-plugin

Conversation

@bexchauveto

@bexchauveto bexchauveto commented Jun 18, 2026

Copy link
Copy Markdown
Member

What

Adds a built-in audit trail to forest_admin_agent: every create / update / delete Forest performs
through its data layer is recorded with who did it, when, and the minimal before/after diff; smart-action
runs are recorded too; and five routes expose that history — including a state reconstruction that
rebuilds a record as it stood at any past instant.

It is off unless an audit database is configured — no route registered, no ActiveRecord loaded, no hook
installed:

ForestAdminRails.configure do |config|
  config.audit_trail = { database: ENV['AUDIT_TRAIL_DATABASE_URL'] } # or an ActiveRecord config hash
  # optional: schema: 'forest', table_name: 'audit_logs', redact: { 'users' => ['email'] }
end

Why

This started as a separate plugin gem (forest_admin_audit_trail). Capturing changes needs the customizer
hooks, the caller's per-request id and the agent's route stack — agent internals the plugin had to reach
into from outside, and users had to wire the same store instance into two different places by hand (the
agent option and agent.use(Plugin, store:)) or silently get a half-working feature. Folding it in turns
that into one config key, and the capture layer is installed by the agent factory so a reload! replays it
like any other customization.

ActiveRecord stays optional: the storage files are autoloaded on first use only, and excluded from Zeitwerk
eager loading (Rails eager loads every gem loader), so agents without an audit database never load it.
Outside Rails, add gem 'activerecord' plus your adapter.

Contents

Capture & storage — packages/forest_admin_agent/lib/forest_admin_agent/audit_trail/

  • Capture — instruments every collection through the customizer hooks; datasource-agnostic, so it behaves
    the same on ActiveRecord, Mongoid, etc. Audits writable columns only, and registers its after hooks ahead
    of everyone else's since execute_after stops at the first exception.
  • ActionCapture — records smart-action runs: operation action / action_failed, the submitted form
    values, one row per selected record.
  • Diff — structural before/after diff (only changed leaves are stored) and revert, its inverse. A key
    absent on one side is left out of that side rather than written as null, so "key added" and "key
    holding null" stay tellable apart without any sentinel value reaching the database — which is what lets a
    revert remove a key instead of resurrecting it as null.
  • RecordState — walks the history backwards to rebuild a record at an instant.
  • Store + Sql::{AuditConnectionBase,AuditLog,Migrator,FieldFilter} — ActiveRecord-backed storage on its
    own connection pool, creating/evolving forest.audit_logs through versioned migrations (advisory-locked
    on Postgres, keyed per target table, run lazily on first use), and reading the history back.
  • Recording — the shared policy: correlation key, redaction, and auditing never breaks the request (a
    failure is logged and the row dropped — by the time we record, the write has happened).

Routes (all under /forest/_audit-trail, all requiring can?(:read, collection) and the caller's
permission scope on the target record)

  • GET /_audit-trail/{collection}/{id} — paginated record history, filterable by userIds, startDate /
    endDate (wall-clock in the request timezone) and fields.
  • GET /_audit-trail/{collection}/{id}/state?timestamp= — the record as it stood then, rebuilt by undoing
    every entry recorded strictly after that instant. data is null when it did not exist yet.
  • GET /_audit-trail/correlation/{key}, GET|POST /_audit-trail/correlations — history grouped by the
    per-request correlation key.

Per-request correlation

  • CorrelationId + CorrelationIdMiddleware — one id per request, exposed on the caller as request_id,
    echoed back in the X-Forest-Correlation-Id header (CORS-exposed), so every change made in one request
    shares a key — and joins to the Forest activity log, which is where the action's name lives. Mounted
    ahead of ShowExceptions: a Rack middleware can only add a header to a response it sees returned, so
    under the exception handlers the 500s they build never carried the id.

Wiring & docs

  • AgentFactory builds the store from the config and installs the capture layer on build;
    ForestAdminRails and ForestAdminRpcAgent expose the audit_trail setting.
  • One shared Hooks#add_handler(prepend:) flag in the customizer (optional, appends as before).
  • packages/forest_admin_agent/AUDIT_TRAIL.md documents configuration, routes, stored columns, migrations
    and the limitations below.

Field filtering, per adapter

fields keeps only entries whose diff touched one of the given names, in SQL so paging and counts stay
correct. Both JSON columns are searched (a field the change added exists in newValues only), and a name is
always a whole key — address.city is quoted, never read as a traversal. Postgres uses jsonb_object_keys
rather than ?| (which ActiveRecord reads as a bind placeholder), SQLite uses json_type rather than
json_extract (a key holding JSON null extracts as SQL NULL, indistinguishable from missing), MySQL /
MariaDB use JSON_CONTAINS_PATH. Any other adapter raises instead of silently returning everything. The
suite runs on SQLite, so the Postgres and MySQL strings are pinned by their own specs.

Limitations, stated on purpose

  • A write that does not go through Forest's data layer is not audited. An action doing
    Customer.find(id).update!(...) is invisible to the agent; only its invocation row exists. Worth auditing
    existing actions before promising customers full coverage.
  • A concurrent overwrite can stale previousValues. Hooks bracket the write as separate calls and the
    data layer exposes no lock (deliberately — it spans ActiveRecord, Mongoid, HTTP APIs), so two writes
    racing on one record snapshot the same state. newValues is always exact and no row is lost. Exact
    before-images under concurrency need triggers or CDC.
  • State reconstruction covers audited columns only — read-only, computed and DB-managed fields are never
    recorded, so they cannot be restored, and a delete's snapshot holds writable columns only.
  • Deleted records keep their history: the scope check refuses only a record that still exists outside
    the caller's scope. Once no record exists there is nothing to evaluate a scope against, so that history is
    readable by anyone with collection read permission — deliberate, the delete event is the point.
  • One response shape is still unconfirmed: the correlation routes camelCase their top-level keys like
    the per-record route. Nothing reads those endpoints yet (no reference on the frontend or agent-nodejs
    default branches), so consistency was the tie-breaker — worth confirming when the Historic tab lands.
    /state now matches Node's handleStateAt: { data } and nothing else.
  • redact masks values but still records the change, and applies to smart-action form values too, keyed by
    collection.

Notes

No new package, so nothing was added to .releaserc.js or the CI matrices; the audit trail ships with
forest_admin_agent. Out of scope but spotted on the way: Utils::QueryStringParser#parse_pagination digs
into params[:page] assuming a Hash, so ?page=foo raises for every list/count route — left alone since it
raises BadRequestError rather than defaulting, so fixing it is a behaviour change of its own.

🤖 Generated with Claude Code

Note

Add audit trail feature to record and expose collection record history gated on an audit database

  • Introduces a full audit trail system that captures create, update, delete, and smart-action operations on Forest collections, storing entries in a SQL-backed store (SQLite, PostgreSQL, MySQL supported).
  • Adds AuditTrail::Capture to instrument collections via hooks, computing minimal diffs per record and supporting per-collection field redaction; AuditTrail::Store handles lazy schema migration and stable paginated queries.
  • Exposes HTTP endpoints under /_audit-trail/:collection/:id (paginated history with user/date/field filters) and /_audit-trail/:collection/:id/state (record state reconstructed at a past timestamp using AuditTrail::RecordState).
  • Adds correlation-based endpoints (/_audit-trail/correlation/:key, /_audit-trail/correlations) and a CorrelationIdMiddleware that generates a per-request UUID and emits it as the x-forest-correlation-id response header.
  • Audit trail is opt-in: configured via audit_trail: { database: ... } in agent options; no store means no routes or capture hooks are installed.
  • Risk: audit capture adds before/after hooks to every instrumented collection operation and performs additional list queries (snapshots) for updates and deletes, increasing DB load per write.

Macroscope summarized 9ac9ffc.

@qltysh

qltysh Bot commented Jun 18, 2026

Copy link
Copy Markdown

Qlty


⚠️ Comments skipped @bexchauveto doesn't have a Qlty seat in ForestAdmin.

Qlty doesn't post analysis or coverage comments for contributors without a seat. An authorized user can grant @bexchauveto a seat from this pull request's page in Qlty.

Comment thread packages/forest_admin_audit_trail/lib/forest_admin_audit_trail/sql/migrator.rb Outdated
Comment thread packages/forest_admin_audit_trail/lib/forest_admin_audit_trail/sql/migrator.rb Outdated
bexchauveto and others added 7 commits August 10, 2026 15:29
Introduce the forest_admin_audit_trail package: a datasource-agnostic
plugin that captures who changed what (before/after diff) for every
change Forest performs through its data layer, with pluggable storage
(in-memory, log, SQL).

Supporting agent/rails wiring:
- correlation id generated per request, propagated to the caller as
  request_id and echoed back via response header (CorrelationIdMiddleware)
- record-history route (/_audit-trail/:collection/:id) reading from a
  configurable store
- register the gem in the semantic-release pipeline (.releaserc.js) and
  exclude its version.rb from rubocop, matching the other packages

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Matches the other packages, which disable MFA and are excluded from the cop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add GET /_audit-trail/correlation/:correlation_key (one key) and the batch
GET/POST /_audit-trail/correlations (correlationKeys list, POST body to dodge
URL limits), both scoped to a record via collection/recordId params, sharing
the per-record auth and store gate. Back them with list_by_correlation /
list_by_correlations on the stores (SQL + in-memory + log no-op).

Register the correlation source before audit_trail so /_audit-trail/correlation
matches it instead of the per-record /_audit-trail/:collection_name/:id (Rails
matches in definition order).

Mirror the Node README: Rails configuration process plus docs for the
record-history, correlation and batch correlation routes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Multiple SqlStore instances mutated the shared Sql::AuditLog.table_name,
so stores against different Postgres schemas clobbered each other. Make
AuditLog an abstract template and build a per-instance concrete subclass
bound to the store's own qualified table.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Without the Postgres advisory lock, two instances booting at once can both
see a migration as pending and both run its create_table/add_index, crashing
the loser with "already exists". Add if_not_exists: true to the table and
index DDL.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
An empty-string schema made schema? true, producing invalid identifiers
like ".audit_migrations". Use present? so nil, "" and whitespace all mean
no schema qualification.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…atabase

The audit trail is no longer a separate gem to install and wire up: capture,
storage and the record-history routes now live in forest_admin_agent and turn
on as soon as `config.audit_trail[:database]` is set.

- the agent factory builds the store from the configured database and installs
  the capture layer through the customizer, so `reload!` replays it
- drop LogStore and InMemoryStore: with no database the feature stays off, and
  the specs cover the store through SQLite
- AuditRecord becomes a Struct, and the capture hooks call their own methods
  instead of going through `send`
- ActiveRecord is required lazily, so agents without an audit database never
  load it

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@bexchauveto
bexchauveto force-pushed the feat/audit-trail-plugin branch from 5af078c to 9fcfeda Compare August 10, 2026 14:00
@qltysh

qltysh Bot commented Aug 10, 2026

Copy link
Copy Markdown

23 new issues

Tool Category Rule Count
qlty Structure Function with many parameters (count = 5): record 15
qlty Structure Function with high complexity (count = 7): record 8

add_delete_hooks(collection_customizer, columns, primary_keys, name, projection)
end

def add_create_hook(collection_customizer, columns, primary_keys, name)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function with many parameters (count = 4): add_create_hook [qlty:function-parameters]

end
end

def add_update_hooks(collection_customizer, columns, primary_keys, name, projection)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function with many parameters (count = 5): add_update_hooks [qlty:function-parameters]

end
end

def add_delete_hooks(collection_customizer, columns, primary_keys, name, projection)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function with many parameters (count = 5): add_delete_hooks [qlty:function-parameters]

Thread.current[:forest_audit_trail_snapshots] ||= {}.compare_by_identity
end

def emit(caller, operation, collection, record_id, previous_values, new_values)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function with many parameters (count = 6): emit [qlty:function-parameters]

Comment thread packages/forest_admin_agent/lib/forest_admin_agent/audit_trail/store.rb Outdated
Comment thread packages/forest_admin_agent/lib/forest_admin_agent/audit_trail/store.rb Outdated
"Invalid date: \"#{raw}\" (expected YYYY-MM-DD or YYYY-MM-DDTHH:mm)"
end

instant.utc.iso8601(3)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function with high complexity (count = 5): parse_date_boundary [qlty:function-complexity]

else
base
end
end

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function with high complexity (count = 8): local_instant [qlty:function-complexity]

request_id: nil,
project: nil,
environment: nil,
**_extra_args

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function with many parameters (count = 15): initialize [qlty:function-parameters]

Comment thread packages/forest_admin_agent/lib/forest_admin_agent/audit_trail/capture.rb Outdated
Comment thread packages/forest_admin_agent/lib/forest_admin_agent/audit_trail/diff.rb Outdated
Comment thread packages/forest_admin_agent/lib/forest_admin_agent/audit_trail/capture.rb Outdated
…e without activerecord

Rails eager loads every Zeitwerk loader, so the audit `sql/` files were pulled
in — and with them `require "active_record"` — in agents that never configure
an audit database (the mongoid package's CI job boots such an app). They are
excluded from eager loading now.

Review findings:

- the history routes only checked `can?(:read, collection)`, so a role
  restricted to a subset of records could read the history of any of them;
  both routes now intersect the record with the permission scope, like Show
- migration bookkeeping keyed the target table, otherwise a store configured
  with another `table_name` read someone else's migrations as done and never
  created its table
- snapshots pair through a bounded LIFO stack instead of the filter object: the
  after-context carries the caller's original filter, so a customization
  replacing it silently dropped the audit record and stranded the snapshot
- the effective patch is snapshotted in the before hook, so a replaced patch is
  recorded as written rather than as sent
- a hash key holding nil is no longer diffed as equal to a missing key

The reported `list` arity issue is a false positive: hook contexts hand out a
RelaxedCollection, whose `list(filter, projection)` already binds the caller.
The capture spec now uses a verifying double of it so the contract is asserted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…afe page parsing

Review follow-ups:

- history stays readable once the record is gone: the scope check now refuses
  only a record that still exists outside the caller's scope, since inspecting
  what was deleted is much of the point of an audit trail
- the correlation routes serialize like the per-record one (camelCase on top,
  column names untouched) instead of leaking snake_case keys; the shared
  behaviour of the audit routes lives in AuditTrailRoute
- `?page=foo` no longer raises: a non-Hash `page` falls back to the documented
  defaults instead of blowing up in Hash#dig

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@bexchauveto bexchauveto changed the title feat(audit_trail): add audit trail plugin gem feat(audit_trail): record and expose record history in the agent, gated on an audit database Aug 11, 2026
@qltysh

qltysh Bot commented Aug 11, 2026

Copy link
Copy Markdown

Qlty


Coverage Impact

⬆️ Merging this pull request will increase total coverage on main by 2.3%.

Modified Files with Diff Coverage (26)

RatingFile% DiffUncovered Line #s
Coverage rating: A Coverage rating: A
...olkit/lib/forest_admin_datasource_toolkit/components/caller.rb100.0%
Coverage rating: C Coverage rating: A
...st_admin_agent/lib/forest_admin_agent/builder/agent_factory.rb100.0%
Coverage rating: A Coverage rating: A
packages/forest_admin_agent/lib/forest_admin_agent/http/router.rb100.0%
Coverage rating: A Coverage rating: A
...ib/forest_admin_datasource_customizer/collection_customizer.rb100.0%
Coverage rating: A Coverage rating: A
...rest_admin_agent/lib/forest_admin_agent/utils/caller_parser.rb100.0%
Coverage rating: A Coverage rating: A
...source_customizer/decorators/hook/hook_collection_decorator.rb100.0%
Coverage rating: A Coverage rating: A
...ib/forest_admin_datasource_customizer/decorators/hook/hooks.rb100.0%
Coverage rating: B Coverage rating: A
...st_admin_agent/lib/forest_admin_agent/routes/action/actions.rb100.0%
New Coverage rating: A
...min_agent/lib/forest_admin_agent/audit_trail/action_capture.rb100.0%
New Coverage rating: A
.../forest_admin_agent/lib/forest_admin_agent/audit_trail/diff.rb100.0%
New Coverage rating: F
packages/forest_admin_rails/lib/forest_admin_rails/engine.rb66.7%37, 126
New Coverage rating: A
...forest_admin_agent/routes/resources/audit_trail_correlation.rb100.0%
New Coverage rating: A
...admin_agent/lib/forest_admin_agent/audit_trail/record_state.rb100.0%
New Coverage rating: A
packages/forest_admin_agent/lib/forest_admin_agent/audit_trail.rb100.0%
New Coverage rating: A
...dmin_agent/lib/forest_admin_agent/audit_trail/sql/audit_log.rb100.0%
New Coverage rating: A
...st_admin_agent/lib/forest_admin_agent/audit_trail/recording.rb100.0%
New Coverage rating: A
...t/lib/forest_admin_agent/routes/resources/audit_trail_route.rb100.0%
New Coverage rating: A
...forest_admin_agent/lib/forest_admin_agent/audit_trail/store.rb100.0%
New Coverage rating: A
...agent/lib/forest_admin_agent/http/correlation_id_middleware.rb100.0%
New Coverage rating: A
...n_agent/lib/forest_admin_agent/routes/resources/audit_trail.rb100.0%
New Coverage rating: A
...admin_agent/lib/forest_admin_agent/audit_trail/audit_record.rb100.0%
New Coverage rating: A
...rest_admin_agent/lib/forest_admin_agent/audit_trail/capture.rb100.0%
New Coverage rating: A
...n_agent/lib/forest_admin_agent/audit_trail/sql/field_filter.rb100.0%
New Coverage rating: B
...ib/forest_admin_agent/audit_trail/sql/audit_connection_base.rb85.7%4
New Coverage rating: A
...rest_admin_agent/lib/forest_admin_agent/http/correlation_id.rb100.0%
New Coverage rating: A
...admin_agent/lib/forest_admin_agent/audit_trail/sql/migrator.rb100.0%
Total99.5%
🤖 Increase coverage with AI coding...
In the `feat/audit-trail-plugin` branch, add test coverage for this new code:

- `packages/forest_admin_agent/lib/forest_admin_agent/audit_trail/sql/audit_connection_base.rb` -- Line 4
- `packages/forest_admin_rails/lib/forest_admin_rails/engine.rb` -- Lines 37 and 126

🚦 See full report on Qlty Cloud »

🛟 Help
  • Diff Coverage: Coverage for added or modified lines of code (excludes deleted files). Learn more.

  • Total Coverage: Coverage for the whole repository, calculated as the sum of all File Coverage. Learn more.

  • File Coverage: Covered Lines divided by Covered Lines plus Missed Lines. (Excludes non-executable lines including blank lines and comments.)

    • Indirect Changes: Changes to File Coverage for files that were not modified in this PR. Learn more.

bexchauveto and others added 3 commits August 12, 2026 09:26
…outes

`GET /_audit-trail/correlations` has two path segments, so the earlier
`/:collection_name/:id` route matched it first and answered "Collection
'_audit-trail' not found". Both audit-trail sources now sit ahead of every route
matching on `:collection_name`; everything registered before them has a literal
first segment, so nothing can shadow them and they shadow nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The customizer has no Execute hook, so the invocation is recorded from the action
route: one row per selected record, `operation` `action` (or `action_failed` when
it raised), the action name in its own column and the submitted form values in
`new_values`, redacted with the same config as columns.

A global action or a select-all selection names no target without querying the
whole selection, so those runs are recorded once, attached to no record.
Recording is best-effort: a failing audit database logs instead of breaking the
action.

What an action *changes* is unchanged: writes going through the Forest data layer
are audited by the existing hooks under the same correlation key, and a direct
ORM write stays invisible — documented rather than worked around.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…y have it

The Forest activity logs record which action ran, and `correlation_key` joins them
to the audit rows, so storing the name again buys nothing. Migration 003 goes with
it: the action row keeps `operation` (`action` / `action_failed`), the submitted
form values and the targeted record ids.

Anyone who ran the intermediate code keeps a stray nullable `action_name` column;
nothing reads or writes it, and it can be dropped by hand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@redact = redact || {}
end

def record(caller:, collection:, form_values:, record_ids:, failed: false)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function with many parameters (count = 5): record [qlty:function-parameters]


# A failed run is worth recording too: "who tried to run this" is usually the interesting part.
# The audit row is best-effort — a broken audit database must not fail the action itself.
def execute_and_audit(context, args, data, filter)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function with many parameters (count = 4): execute_and_audit [qlty:function-parameters]

raise
end

def audit_action(context, args, data, failed: false)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function with many parameters (count = 4): audit_action [qlty:function-parameters]

Comment thread packages/forest_admin_agent/lib/forest_admin_agent/audit_trail/capture.rb Outdated
… gaps

Recording a change raised through the after hook when the audit store was
unavailable, so a write that had already succeeded came back as an error the
client could retry into a duplicate. Auditing is best-effort now, in one place
(`Recording#audit_safely`): the failure is logged and the row dropped, for the
change capture, the snapshot read of a before hook (which would otherwise block
the write) and the action capture alike.

A failed snapshot read pushes an empty snapshot rather than nothing, so the after
hook still pops its own entry instead of an unrelated one.

Coverage of the new code, following the qlty report:

- the action audit path, previously untested (72% diff coverage on actions.rb)
- the Postgres-only migrator branches — advisory lock, schema creation and its
  concurrent-create rescue — through a fake adapter, no server needed
- the wall-clock datetime filters, documented but never exercised, including an
  out-of-range instant
- the correlation routes' unknown-collection 404 and its re-raise
- three route registrations now dispatch through their closure instead of calling
  the handler, so the wiring is covered by the tests that were already there

Every audit-trail file is at 100% except the `activerecord` LoadError guard,
which needs a bundle without ActiveRecord to reach.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
)
)
end
end

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function with high complexity (count = 7): record [qlty:function-complexity]

…us_values

Hooks bracket the write as separate calls and the data layer exposes no lock or
transaction primitive — deliberately, since it spans ActiveRecord, Mongoid, HTTP
APIs and more. Two writes racing on one record therefore snapshot the same state,
and the second records a previous_values that was already overwritten.

Nothing to fix inside the capture layer: reading the snapshot in the write's
transaction is not something a hook can do, and after the write the before-state
is gone. Documented in the code and in AUDIT_TRAIL.md, next to the ORM-write
limitation it belongs with.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread packages/forest_admin_agent/lib/forest_admin_agent/audit_trail/capture.rb Outdated
`Hooks#execute_after` stops at the first exception, and the capture layer is
installed last, so any customization raising in its own after hook dropped the
audit row of a write that had already happened — and stranded its snapshot.

`add_hook` takes a `prepend` flag now (optional, defaults to appending as before),
and the capture layer prepends its after hooks so nothing can preempt them. Its
before hooks stay appended, so the snapshot still reads the filter and patch every
other customization has had its say on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# end
def add_hook(position, type, &handler)
push_customization { @stack.hook.get_collection(@name).add_hook(position, type, handler) }
def add_hook(position, type, prepend: false, &handler)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function with many parameters (count = 4): add_hook [qlty:function-parameters]


def add_hook(position, type, hook)
@hooks[type].add_handler(position, hook)
def add_hook(position, type, hook, prepend: false)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function with many parameters (count = 4): add_hook [qlty:function-parameters]

Parity with the Node agent, plus the two gaps found on the way.

- `GET /_audit-trail/:collection/:id/state?timestamp=` rebuilds a record by
  undoing every entry recorded strictly after the instant (an entry stamped
  exactly at it belongs to that state). A create means the record did not exist
  then; a delete restores the row it recorded and the walk carries on into an
  earlier life of the same id.
- The diff now leaves a key out of the side where it does not exist instead of
  writing nil, so "key added" and "key holding nil" stay tellable apart —
  `Diff.revert` needs that, and a round-trip property spec pins it. No sentinel
  value reaches the database.
- `fields` filter on the history route, with the JSON key test per adapter
  (Postgres / SQLite / MySQL, raising on anything else) in its own FieldFilter
  class. Field names are quoted as whole keys, so a name holding a dot is not
  read as a path, and both sides of the diff are searched since an added key
  exists on the new side only.
- The correlation-id middleware goes ahead of ShowExceptions: a Rack middleware
  can only add a header to a response it sees returned, so sitting under the
  exception handlers meant the 500s they build never carried the id.
- The schema-already-exists rescue checks SQLSTATE (42P06 / 23505) where the
  adapter exposes one, and RecordNotUnique by class, instead of matching
  "already exists" anywhere in a message.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
next_values[key] = sub[:next] unless sub[:next].equal?(ABSENT)
end

{ previous: previous, next: next_values }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function with high complexity (count = 7): diff_hashes [qlty:function-complexity]

# created the schema between our IF NOT EXISTS check and the create itself.
nil
rescue ActiveRecord::StatementInvalid => e
raise unless duplicate_schema?(e)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function with high complexity (count = 5): ensure_schema [qlty:function-complexity]

model.create!(to_row(record))
end

def list_by_record(collection:, record_id:, skip: 0, limit: nil, user_ids: nil,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function with many parameters (count = 9): list_by_record [qlty:function-parameters]

relation.map { |row| from_row(row) }
end

def count_by_record(collection:, record_id:, user_ids: nil, start_timestamp: nil,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function with many parameters (count = 6): count_by_record [qlty:function-parameters]


private

def scope(collection, record_id, user_ids, start_timestamp, end_timestamp, fields = nil)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function with many parameters (count = 6): scope [qlty:function-parameters]

…cises

The specs run on SQLite, so the Postgres and MySQL field filters were never
executed. FieldFilter only builds a condition string, so a fake connection is
enough to pin all three — including that neither emits a `?` (a bind placeholder
for ActiveRecord) nor `json_extract` (which cannot tell a key holding null from a
missing one).

Also covers the SQLSTATE read failing, and dispatches the state route through its
registered closure so the wiring is exercised like the other three.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread packages/forest_admin_agent/lib/forest_admin_agent/audit_trail/diff.rb Outdated
…e timezone

Two review findings, both hidden by specs that asserted an encoding by hand
instead of the one the diff actually emits.

- `diff_object_arrays` recorded `previous[index] = nil` for an index only the new
  side reaches, so reverting an append left a nil hole instead of shortening the
  array. Indexes now follow the same rule as hash keys — left out of the side that
  does not reach them — and the round-trip spec carries length changes so an
  append and a drop are both covered.
- `parse_state_timestamp` handed `2026-01-02T08:30:15` to Time.iso8601, which
  parses it happily in the server's timezone and ignores the request's. Wall-clock
  values (with or without seconds, and bare days) go through the request timezone;
  Time.iso8601 is left for values carrying an offset or Z.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
next_values[index] = sub[:next] if index < after.length
end

{ previous: previous, next: next_values }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function with high complexity (count = 7): diff_object_arrays [qlty:function-complexity]

`handle_state` authorized the record with a scoped query and then read its columns
with an unscoped one, so the row it returned was not the row the check covered.

Authorization and read are now the same query: `scoped_record` returns the record
matched against the caller's scope (nil when it is simply gone, 404 when it exists
outside that scope), and `assert_record_in_scope` is that same call with its result
discarded. Nothing reads the row around the scope any more, and /state does one
query instead of two.

The spec that was meant to cover this passed for the wrong reason — it consumed the
primary-key stub as the record, so the full read was never exercised. It now asserts
the scope is in the filter, that every column is projected and that a single query
happens, plus the two branches around it: a record living outside the scope, and a
deleted one rebuilt from its history.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#
# Authorizing and reading are the same query on purpose: a scoped check followed by an unscoped read
# would hand back a row the check never covered, the moment the two drifted apart.
def scoped_record(context, collection, packed_id, projection = nil)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function with many parameters (count = 4): scoped_record [qlty:function-parameters]

Projection.new(ForestAdminDatasourceToolkit::Utils::Schema.primary_keys(collection))
end

def first_record(context, collection, condition_tree, projection)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function with many parameters (count = 4): first_record [qlty:function-parameters]

bexchauveto and others added 3 commits August 13, 2026 10:31
…e agent

Node's handleStateAt answers with `{ data }` and nothing else, so the timestamp
and reverted-entry count go: they were ours to invent and nothing reads them.
`timestamp` and `entries` are still needed to query and rebuild, only the meta
hash is gone.

The spec now pins the whole payload rather than asserting on the two keys, so a
stray meta key fails instead of passing unnoticed, and AUDIT_TRAIL.md drops the
note about the shape being unconfirmed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ion stack

An insert_before recorded on the engine's own middleware proxy does not see
ShowExceptions and raises when the stack is applied — too late for the rescue
here, which only guards the recording. It goes to Rails.application's stack
instead, where the exception handlers actually live.

The spec followed the engine's proxy, so it was updated to the application's, and
gained the case where there is no application to reach at all (the rescue covers
it by appending to the engine stack, as before).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An action reporting failure through `result_builder.error` never raises, so the
run was recorded as `action`. It answers with `{ type: 'Error', ... }`, which is
as failed as a raised one — same rule as the Node agent's `result.type === 'Error'`
— and the result is still returned to the caller untouched.

The result-type read is guarded, since it happens before ActionResult.parse and an
action need not answer with a hash at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant