Skip to content

fix(audit-trail): close the remaining scope oracles and parity gaps with Ruby [PRD-1269] - #1983

Merged
bexchauveto merged 9 commits into
mainfrom
feature/prd-1269-agent-nodejs-the-search-filter-lets-a-non-admin-test-the
Oct 21, 2026
Merged

bexchauveto merged 9 commits into
mainfrom
feature/prd-1269-agent-nodejs-the-search-filter-lets-a-non-admin-test-the

Conversation

@bexchauveto

@bexchauveto bexchauveto commented Oct 7, 2026 •

Copy link
Copy Markdown
Member

Closes PRD-1269.

Item 1, the timeline search oracle: resolved by removing the admin gate

The oracle existed because the timeline blanked values for non-admins while search still matched them. #1911 dropped the admin gate before it shipped: a caller who can read a collection gets its timeline values, the same ones they read record by record. There is nothing withheld left for search to probe, so this PR carries no change for it.

Item 2, parity gap 1: search escaping and the redaction mask (commit a0dda89d16)

Mirrors agent-ruby's Sql::TextSearch and search_matches?, in both the SQL search and the served-value matcher:

  • the term is JSON-escaped for the value columns, so 15" monitor (stored as 15\" monitor) is found, and a bare quote no longer matches the JSON structure;
  • [redacted] is removed from the value text before matching, so search=redacted no longer confirms which rows hold a masked value. Same as Ruby, a real value containing that literal loses it for matching.

Item 3, parity gap 2: re-reading a record already gone (same commit)

Aligned with Ruby: recheckRecordVisibility re-reads whenever a scope applies, even for a record gone at the first check, and gone at either read means gone. An id since taken by a record the caller cannot read answers 404; one taken by an in-scope record does not release the earlier values. The history, /state and correlation routes all go through it, including the history route's early path for a gone record with search / fields.

Item 4, a recreated id serves the earlier life's values (commits 54f54904ac, b31f9594db, 4f557cf626, f4768ce8c)

A live record in the caller's scope says nothing about the rows filed under its id before it took the id. The boundary between the two lives is the id's latest confirmed delete or create, whichever is later (lifeBoundaryOf, in audit-trail/earlier-life.ts):

  • a boundary delete is the earlier record's last row, a boundary create the current record's first;
  • the create covers an id freed with no confirmed delete: a primary-key move, or a delete that was never confirmed;
  • a pending row marks no boundary, since it may never have landed;
  • a create with nothing filed before it began the id's only life and is no boundary either. Without that, a record created through Forest, whose own create is its latest boundary, sent every scoped search / fields request down the served-value scan.

Rows before the boundary go through the gone-record withholding even though a live record holds the id now:

  • history route: those rows are withheld, and with search / fields the matching runs on served values, so count and authors cannot leak them;
  • correlation lookups: same row rule;
  • /state: a reconstruction strictly before the boundary is tested against the scope. At the boundary's own instant the state already includes a replacement create sharing it.

withholdEarlierLives holds the gone-or-earlier-life decision once for the history and correlation routes. The lookup runs only for a scoped caller on a live record. Ruby still needs this: PRD-1258.

These are permission-scope withholdings, independent of the admin level, so they stay after the gate removal.

Refactor in the same commits

value-filters.ts is split out of sql-store.ts: the SQL search and the served-value matcher share one column list. The helpers stay exported from packages/agent/src/audit-trail.

The audit trail's dependencies ship with the agent (commit 0f1864e14)

So that turning the audit trail on takes the auditTrail option and nothing else, whatever the main datasource:

  • sequelize and umzug stay regular dependencies (commit 144864d65 had made them optional peers; it is reverted);
  • the PostgreSQL driver, pg and pg-hstore, joins them. The audit trail only targets PostgreSQL today, and both are pure JS, so they add no native build to an agent that never enables it.

Migration 003: index a record's history (commit 410977157)

Item 4's lifeBoundaryOf runs on every read by a caller with a scope, on a record that still exists. With only record_id and (timestamp, id) to choose from, its oldest-row lookup (ASC LIMIT 1) walked the whole table's (timestamp, id) index. On a 2.1M-row Postgres table, a hot record created 30 days ago discarded 2.1M rows (534 ms).

Migration 003 adds:

  • (collection, record_id, timestamp, id), which serves every per-record read;
  • the same columns over create/delete rows only (partial index), for the boundary lookup.

Both lookups drop to ~0.05 ms whatever the table or history size; checked with EXPLAIN on Postgres 15, on a database upgraded from 002. MySQL/MariaDB have no partial index and get the first one only. Ruby: ForestAdmin/agent-ruby#416.

Tests

  • Search: a quote and a backslash are found; a bare quote and redacted match nothing; same on the served-value path.
  • Re-read: an id gone then taken out of scope answers 404; taken in scope keeps withholding.
  • Recreated id: earlier rows withheld and current kept; a search cannot find the earlier values; with a delete never confirmed, the rows before the current create are still withheld; the rows of a record whose primary key moved off the id are withheld; an id never freed is served whole; a scoped search on a record created through Forest stays a single store query; /state before the delete withheld, after it served, and at a delete shared with a replacement create served. The boundary tests fail when the check is disabled or <= is used.

🤖 Generated with Claude Code

Note

Fix audit-trail scope oracles and close parity gaps with Ruby

Closes leaks where a caller who can read a current record also saw captured values from an earlier record that reused the same id.

  • Adds earlier-life detection in earlier-life.ts: auditTrail.lifeBoundaryOf finds the latest confirmed create/delete boundary, and auditTrail.withholdEarlierLives withholds pre-boundary rows for live reused ids
  • Updates history, correlation-history, and state-reconstruction handlers in audit-trail.ts and audit-trail-correlation.ts to recheck visibility for gone records and withhold earlier-life values, counts, authors, and states
  • Moves SQL and in-memory value filters into value-filters.ts; search now uses JSON-escaped terms and strips the redaction mask before matching, and the SQL and served-value paths share the same matcher
  • Adds migration 003 in migrations.ts creating record-history and life-boundary indexes, and adds the PostgreSQL client driver as an agent dependency
  • Behavioral Change: scoped requests for an id reused by an out-of-scope record now return not found instead of earlier-life values; masked values no longer match search; full served-value scans replace SQL-filtered responses when earlier lives affect the result

Macroscope summarized 3147db0.

@linear-code

linear-code Bot commented Oct 7, 2026

Copy link
Copy Markdown

PRD-1269

@bexchauveto
bexchauveto added this pull request to stack #1915 October 7, 2026 14:00
@bexchauveto bexchauveto changed the title fix(audit-trail): keep a non-admin's timeline search off the withheld values [PRD-1269] fix(audit-trail): close the remaining value oracles and parity gaps [PRD-1269] Oct 7, 2026
@qltysh

qltysh Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

6 new issues

Tool Category Rule Count
qlty Structure Function with many returns (count = 6): fieldsChangedCondition 3
qlty Structure Function with high complexity (count = 19): handleHistory 2
qlty Structure Function with many parameters (count = 4): scanServedValues 1

Comment thread packages/agent/src/routes/access/audit-trail.ts Outdated
Comment thread packages/agent/src/audit-trail/sql-store.ts Outdated
@qltysh

qltysh Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

Qlty


Coverage Impact

This PR will not change total coverage.

Modified Files with Diff Coverage (8)

RatingFile% DiffUncovered Line #s
Coverage rating: A Coverage rating: A
packages/agent/src/audit-trail/index.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent/src/audit-trail/migrations.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent/src/routes/access/audit-trail-correlation.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent/src/audit-trail/record-visibility.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent/src/routes/access/audit-trail.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent/src/audit-trail/sql-store.ts100.0%
New file Coverage rating: A
packages/agent/src/audit-trail/value-filters.ts100.0%
New file Coverage rating: A
packages/agent/src/audit-trail/earlier-life.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.

@bexchauveto
bexchauveto force-pushed the feature/prd-1269-agent-nodejs-the-search-filter-lets-a-non-admin-test-the branch 2 times, most recently from 8fdcc29 to 280642a Compare October 7, 2026 15:01
@bexchauveto
bexchauveto force-pushed the feature/prd-1269-agent-nodejs-the-search-filter-lets-a-non-admin-test-the branch from 280642a to 153870a Compare October 8, 2026 13:23
@bexchauveto
bexchauveto force-pushed the feature/prd-1269-agent-nodejs-the-search-filter-lets-a-non-admin-test-the branch from 153870a to e13e23e Compare October 8, 2026 15:22
@bexchauveto bexchauveto changed the title fix(audit-trail): close the remaining value oracles and parity gaps [PRD-1269] fix(audit-trail): close the remaining scope oracles and parity gaps with Ruby [PRD-1269] Oct 8, 2026
@bexchauveto
bexchauveto force-pushed the feature/prd-1269-agent-nodejs-the-search-filter-lets-a-non-admin-test-the branch 4 times, most recently from 13e339b to ab7d8e5 Compare October 12, 2026 07:51

@bexchauveto bexchauveto left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Spec (PRD-1269): conforms. Item 1 is closed by the ticket's 9 October note, and both parity gaps and the recreated id are addressed.

Comment thread packages/agent/src/routes/access/audit-trail.ts
Comment thread packages/agent/src/routes/access/audit-trail.ts Outdated
Comment thread packages/agent/src/routes/access/audit-trail.ts Outdated
Comment thread packages/agent/src/audit-trail/sql-store.ts Outdated
Comment thread packages/agent/src/audit-trail/README.md
@bexchauveto
bexchauveto force-pushed the feature/prd-1269-agent-nodejs-the-search-filter-lets-a-non-admin-test-the branch 2 times, most recently from e31cef3 to 56ce7f7 Compare October 13, 2026 08:08
@bexchauveto
bexchauveto force-pushed the feature/prd-1269-agent-nodejs-the-search-filter-lets-a-non-admin-test-the branch 4 times, most recently from f484511 to 4056d33 Compare October 14, 2026 09:19
Base automatically changed from feature/prd-1321-agent-nodejs-carry-the-id-a-primary-key-move-came-from to main October 14, 2026 09:28
@bexchauveto
bexchauveto force-pushed the feature/prd-1269-agent-nodejs-the-search-filter-lets-a-non-admin-test-the branch 3 times, most recently from f4768ce to ffc12dc Compare October 15, 2026 11:33
@bexchauveto
bexchauveto force-pushed the feature/prd-1269-agent-nodejs-the-search-filter-lets-a-non-admin-test-the branch from c7f8a07 to 144864d Compare October 15, 2026 11:50
@bexchauveto
bexchauveto force-pushed the feature/prd-1269-agent-nodejs-the-search-filter-lets-a-non-admin-test-the branch 2 times, most recently from acc47a8 to 2e55539 Compare October 16, 2026 14:06
Comment thread packages/agent/src/routes/access/audit-trail-correlation.ts
@bexchauveto
bexchauveto force-pushed the feature/prd-1269-agent-nodejs-the-search-filter-lets-a-non-admin-test-the branch 2 times, most recently from bd63011 to 2df4f5f Compare October 21, 2026 09:17
bexchauveto and others added 9 commits October 21, 2026 14:50
…n mask, and re-read a gone record [PRD-1269]

Parity with agent-ruby on two gaps.

Search: the values are matched as serialized JSON, where a quote or a
backslash sits escaped, so the term is now escaped the same way. That
finds '15" monitor' and keeps a bare quote off the document's
structure. The [redacted] mask is removed before matching, so
search=redacted no longer confirms which rows hold a masked value. Same
change on the served-value path.

Re-read: the record is read again after the audit read even when it was
already gone at the first check, and gone at either read means gone. An
id taken since by a record the caller cannot read answers 404, and one
taken by an in-scope record does not release the earlier life's values.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e id [PRD-1269]

A delete frees the id and a later record can take it. Both reads then
see a live record in scope, so the route served the whole history as
the current record's, including values the caller's scope would have
withheld on the record that held the id before.

Rows filed at or before the id's last confirmed delete now go through
the gone-record withholding: on the history route (search and fields
matched against the served values, so count and authors cannot leak
them), on the correlation lookups, and on /state for a reconstruction
at or before that delete. A pending delete frees nothing.

The history route's early path for a record gone at the first check
also re-reads it now, so an id since taken by a record the caller
cannot read answers 404 there too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e earlier record's [PRD-1269]

At the delete's own instant the state already reflects every row there,
including a replacement create sharing that timestamp. Testing it
against the scope as an earlier life withheld the replacement whenever
the scope read a column the capture never kept, although the caller had
just been shown to read it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…D-1269]

A primary-key move or an unconfirmed delete frees an id without a confirmed delete, so the
latest confirmed create also marks where the current record's life starts.

The gone-or-earlier-life decision now lives once in earlier-life.ts, and the SQL and served
search matchers share one column list in value-filters.ts, split out of sql-store.ts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…an [PRD-1269]

A record created through Forest has its own create as the latest boundary, so
every scoped search or fields request on it scanned the whole history to match
on served values. A create with nothing filed before it began the id's only
life, and no longer counts as a boundary. Same fix as agent-ruby #410.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he audit trail [PRD-1269]

The audit trail store is loaded only when `auditTrail` is configured, so an agent without it no
longer installs nor loads sequelize and umzug at boot. An agent that enables it without them gets
an error naming the packages to install: `npm install sequelize umzug`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…1269]

Reverts the optional peer dependencies of 144864d: sequelize and umzug are regular dependencies
again, and the PostgreSQL driver (pg, pg-hstore) joins them, so the auditTrail option alone turns
the audit trail on, whatever the main datasource.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…project timeline [PRD-1323]

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…PRD-1269]

Migration 003 adds (collection, record_id, timestamp, id), and the same columns over create
and delete rows only. Without them, lifeBoundaryOf's oldest-row lookup walked the whole
table's (timestamp, id) index: 534 ms on a 2.1M-row table for a recently created record,
0.04 ms with them. MySQL and MariaDB have no partial index and get the first one only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@bexchauveto
bexchauveto force-pushed the feature/prd-1269-agent-nodejs-the-search-filter-lets-a-non-admin-test-the branch from 4109771 to 3147db0 Compare October 21, 2026 12:50

@arnaud-moncel arnaud-moncel left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM

@bexchauveto
bexchauveto merged commit 669ef58 into main Oct 21, 2026
48 checks passed
@bexchauveto
bexchauveto deleted the feature/prd-1269-agent-nodejs-the-search-filter-lets-a-non-admin-test-the branch October 21, 2026 13:50
forest-bot added a commit that referenced this pull request Oct 21, 2026
## @forestadmin/agent [1.110.4](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/agent@1.110.3...@forestadmin/agent@1.110.4) (2026-10-21)

### Bug Fixes

* **audit-trail:** close the remaining scope oracles and parity gaps with Ruby [PRD-1269] ([#1983](#1983)) ([669ef58](669ef58))

### Dependencies

* **@forestadmin/workflow-executor:** upgraded to 1.35.0
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.

2 participants