Skip to content

feat(agent): reconstruct a record state at a past timestamp from audit logs#1682

Open
ShohanRahman wants to merge 4 commits into
feat/compliant-audit-trailfrom
feat/audit-trail-record-state-route
Open

feat(agent): reconstruct a record state at a past timestamp from audit logs#1682
ShohanRahman wants to merge 4 commits into
feat/compliant-audit-trailfrom
feat/audit-trail-record-state-route

Conversation

@ShohanRahman

@ShohanRahman ShohanRahman commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Summary

  • New route GET /_audit-trail/{collection}/{id}/state?timestamp=... next to the existing history route, stacked on top of feat(audit-trail): create compliant audit trail #1670.
  • Reads the record's current value through the regular collection API (so auth scopes apply) and replays every audit entry with timestamp >= target in reverse to reconstruct the state at the target instant.
  • Walking back stops on a terminal event: create → 404 (record did not exist yet), delete → adopts its previousValues snapshot and ignores older entries. Only audited columns (writable + PKs) are returned.

Notes

  • The revert logic lives in the agent next to the route (audit-trail-revert.ts) rather than in plugin-audit-trail so the agent runtime keeps its current decoupling from the plugin — the same trade-off already taken with AuditTrailRecordReader in agent/src/types.ts.
  • The diff/revert structure (nested objects, array-of-objects with numeric keys, primitive arrays kept whole) mirrors what the plugin produces.

Test plan

  • 11 unit tests for revertRecord covering single update, multi-step walkback, nested objects, array-of-objects, primitive-array replacement, create stop, delete stop (deleted now and recreated), no-mutation.
  • 10 route tests covering registration, store query shape, timestamp parsing (missing/malformed/valid), auth assertion, audited-columns projection, no-op when log is empty, multi-step revert, 404 on empty + missing record, 404 on create walkback, delete snapshot when record is gone.
  • yarn workspace @forestadmin/agent test — 979 tests pass.
  • yarn workspace @forestadmin/agent lint — clean.

🤖 Generated with Claude Code

Note

Add endpoint to reconstruct a record's state at a past timestamp from audit logs

  • Adds a new GET /_audit-trail/<collection>/:id/state?at=<timestamp> endpoint in audit-trail.ts that returns an audited record's column snapshot as of a given time, or 404 if the record did not exist then.
  • Adds audit-trail-revert.ts with a revertRecord function that walks audit entries newest-first, applying update reversions recursively for nested objects and arrays, and terminates on create/delete entries.
  • Extends QueryStringParser.parseCaller in query-string.ts to accept an optional defaultTimezone, used when the query lacks one (defaults to UTC for the state endpoint).
  • The at parameter accepts wall-clock datetimes with a timezone query param, or ISO 8601 instants with embedded offsets (which bypass the request timezone).

Macroscope summarized 6576c15.

…t logs

Exposes `GET /_audit-trail/{collection}/{id}/state?timestamp=...` next to the existing history
route. The handler reads the record's current value and replays every audit entry with
`timestamp >= target` in reverse, applying `previousValues` to undo each change. Walking back
stops on a terminal event: a `create` returns 404 (record did not exist yet at the target
instant) and a `delete` adopts its `previousValues` snapshot and ignores anything older. Only
the audited columns (writable + primary keys) are returned — read-only/computed fields are not
captured by the audit log and can't be reconstructed.

The revert logic lives next to the route in the agent rather than in the plugin so the agent
runtime doesn't take a new dependency on `plugin-audit-trail` (the agent already locally
re-declares the audit store interface).

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

qltysh Bot commented Jun 18, 2026

Copy link
Copy Markdown

2 new issues

Tool Category Rule Count
qlty Structure Function with high complexity (count = 12): revertValue 1
qlty Structure Function with many returns (count = 5): toLocalInstant 1

Comment on lines +63 to +72
export default function revertRecord(
current: Record<string, unknown> | null,
entries: AuditEntry[],
): Record<string, unknown> | null {
let state = current;

for (const entry of entries) {
if (entry.operation === 'create') return null;

if (entry.operation === 'delete') return { ...entry.previousValues };

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium access/audit-trail-revert.ts:63

When revertRecord encounters a delete entry, it immediately returns entry.previousValues without reverting any older entries in the list. This produces the wrong state when entries exist between the target time and the deletion. For example, if the record was updated at t=1 and deleted at t=2, and the target is t=0.5, the function returns the state at deletion time ({a:2}) instead of reverting the update to get the state at t=0.5 ({a:1}). The loop should continue applying reverts to entry.previousValues instead of returning it directly.

-    if (entry.operation === 'delete') return { ...entry.previousValues };
+    if (entry.operation === 'delete') state = { ...entry.previousValues };
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/agent/src/routes/access/audit-trail-revert.ts around lines 63-72:

When `revertRecord` encounters a `delete` entry, it immediately returns `entry.previousValues` without reverting any older entries in the list. This produces the wrong state when entries exist between the target time and the deletion. For example, if the record was updated at t=1 and deleted at t=2, and the target is t=0.5, the function returns the state at deletion time (`{a:2}`) instead of reverting the update to get the state at t=0.5 (`{a:1}`). The loop should continue applying reverts to `entry.previousValues` instead of returning it directly.

Evidence trail:
packages/agent/src/routes/access/audit-trail-revert.ts lines 63-78 at REVIEWED_COMMIT: `revertRecord` function. Line 72: `if (entry.operation === 'delete') return { ...entry.previousValues };` — immediate return prevents processing of older entries. Lines 54-61: docstring incorrectly claims 'the snapshot already contains everything that happened before the deletion'. Lines 37-52: `revertOne` function that would correctly revert an update entry against a state — but is never reached for entries after a delete.

@qltysh

qltysh Bot commented Jun 18, 2026

Copy link
Copy Markdown

Qlty


Coverage Impact

Unable to calculate total coverage change because base branch coverage was not found.

Modified Files with Diff Coverage (3)

RatingFile% DiffUncovered Line #s
New Coverage rating: A
packages/agent/src/utils/query-string.ts100.0%
New Coverage rating: A
packages/agent/src/routes/access/audit-trail-revert.ts100.0%
New Coverage rating: A
packages/agent/src/routes/access/audit-trail.ts100.0%
Total100.0%
🚦 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.

};

const isPlainObject = (value: unknown): value is Record<string, unknown> =>
Boolean(value) && typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

overkill

Boolean(value) && typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date);

// Plain object on both sides ⇒ nested diff; otherwise `previous` is the leaf to write back.
const revertValue = (current: unknown, previous: unknown, next: unknown): unknown => {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

ts not very useful. can we delete it ?

Comment on lines +23 to +24
// ISO 8601 instant: carries its own timezone designator (`Z` or `±HH:mm` / `±HHMM`).
const ISO_INSTANT = /[Zz]$|[+-]\d{2}:?\d{2}$/;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

TODO: check if there is a better format for a timestamp with timezone ?

Comment on lines +113 to +121
private static auditedColumns(schema: CollectionSchema): string[] {
const writable = Object.keys(schema.fields).filter(name => {
const field = schema.fields[name];

return field.type === 'Column' && !field.isReadOnly;
});

return [...new Set([...SchemaUtils.getPrimaryKeys(schema), ...writable])];
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

is it necessary to filter out the readonly fields ? because they will not be changed in the audit logs

instant.invalidReason === 'unsupported zone'
? `Invalid timezone: "${timezone}"`
: `Invalid date: "${raw}" (expected YYYY-MM-DD or YYYY-MM-DDTHH:mm)`,
: `Invalid date: "${raw}" (expected YYYY-MM-DD, YYYY-MM-DDTHH:mm, or an ISO 8601 instant)`,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

IMO we should only expect one format: the one sent by the frontend

Comment on lines +222 to +225
// An embedded offset already pins the instant — the request timezone and start/end boundary
// don't apply.
if (ISO_INSTANT.test(raw)) return DateTime.fromISO(raw, { setZone: true });

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

unclear

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