Skip to content

fix(audit-trail): withhold on every route that serves captured values [PRD-1295] - #396

Open
bexchauveto wants to merge 11 commits into
mainfrom
feature/prd-1295-audit-trail-state-and-the-correlation-routes-bypass-the
Open

bexchauveto wants to merge 11 commits into
mainfrom
feature/prd-1295-audit-trail-state-and-the-correlation-routes-bypass-the

Conversation

@bexchauveto

@bexchauveto bexchauveto commented Sep 25, 2026 •

Copy link
Copy Markdown
Member

Closes PRD-1295 — the Ruby half. The Node half landed in agent-nodejs#1909.

The leak

PRD-1226 made the history route withhold a gone record's captured values from a caller whose record-level scope those values fail. The other three routes serve the same rows and did not:

GET /_audit-trail/projects/4                     -> previousValues: {}
GET /_audit-trail/projects/4/state?timestamp=..  -> {"status": "someone else secret"}
GET /_audit-trail/correlation/req-1              -> previousValues: {"status": "someone else secret"}

What changed

The predicate moves out of the history route into AuditTrailWithholding, included by AuditTrailRoute, so all four routes share one rule — mirroring Node's audit-trail/withhold.ts. Nothing about the rule itself changed; the history route's behaviour is unchanged.

  • Correlation routes (single and batch) withhold row by row, exactly as the history route does. assert_record_in_scope already returned the scope needed to do it; the return value was being dropped.
  • /state takes option 2 from the ticket, as Node did: the reconstruction is tested as a whole and data is null when it fails, rather than 404. A scoped caller keeps the legitimate "what did this deleted record look like" the route exists for.
  • Both take the second read of the record the history route takes, so the answer that decides the withholding is never older than the rows it applies to. Skipped when there is nothing to withhold — no scope in effect, or no rows in the answer.

One difference on /state: a reconstruction can sit on the far side of a primary-key move the route cannot see, so the requested id fills in only a key that was never captured at all (read-only, so it cannot have moved), never one the trail redacted. On a row the id is authoritative, because a row is filed under an id that was true of the side being tested — previous_record_id carries the other one.

Tests

13 new examples, 1270 green in the package. Each of the six behaviours was mutation-tested — reverted one at a time, confirming a spec fails:

reverted failures
/state does not withhold at all 4
/state lets the requested id answer for a redacted key 1
/state skips the second read 1
correlation does not withhold 3
correlation skips the second read 2
correlation reads the record again on an empty history 1

AUDIT_TRAIL.md loses the "this covers the history route only" caveat.

🤖 Generated with Claude Code

Note

Apply scope-based withholding to all audit-trail routes serving captured values

  • Adds a shared AuditTrailWithholding module used by the history, state, and correlation routes, so captured previous_values/new_values that fail the caller's scope are blanked while event metadata is kept (audit_trail_withholding.rb)
  • Audit-history requests for gone records with search or field filters now match against served (post-withholding) values, using 500-row cursor batches in each_withheld_batch, with matching count, page, and first-page availableUsers derived from the same path
  • GET /_audit-trail/:collection_name/:id/state returns nil data when the reconstructed state cannot satisfy the caller's scope
  • AuditTrailWithholding.matches_as_stored? treats captured NULLs as non-matching for NOT_EQUAL, NOT_IN, and NOT_CONTAINS, fixing a scope-check leak; scope checks also no longer treat redaction markers as values
  • Behavioral Change: routes now perform a second scoped record read after fetching audit rows and return 404 when the id is occupied by an out-of-scope record; AuditTrail::Store.list_by_record supports timestamp/id cursor continuation

Macroscope summarized 1ad5d56.

… [PRD-1295]

The history route withheld a gone record's captured values from a caller
whose permission scope they fail; `/state` reassembled the same values and
served them unfiltered, and the two correlation routes checked the record
but not the values. The same caller read one request away what the history
route had just withheld.

The withholding moves to `AuditTrailWithholding`, shared by all four routes,
and `/state` answers `{ "data": null }` when the reconstruction fails the
scope — the decision taken on the Node side in agent-nodejs#1909.

One difference on `/state`: a reconstruction can sit on the far side of a
primary-key move the route cannot see, so the requested id fills in only a
key that was never captured at all, never one the trail redacted.

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

linear-code Bot commented Sep 25, 2026

Copy link
Copy Markdown

PRD-1295

@qltysh

qltysh Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

13 new issues

Tool Category Rule Count
qlty Structure Function with many parameters (count = 11): list_by_record 9
qlty Structure Function with high complexity (count = 12): history_matched_after_withholding 3
qlty Structure High total complexity (count = 57) 1

# The history route withholds a gone record's captured values from a caller whose scope they fail, and
# this route is nothing but those values reassembled: without the same test they come back one request
# away. A reconstruction the scope cannot answer withholds too — absent is not the same as passing.
def answerable_state(state, scope, context, packed_id)

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): answerable_state [qlty:function-parameters]

# the scope doesn't apply to them.
else
entry
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 = 6): withhold [qlty:function-complexity]

# matching. The capture keeps the writable columns, so a scope on anything else — a read-only column, a
# relation — reads as nil there and would answer for a value the row never held: `status != 'private'`
# would match, and an ordered operator would raise on the nil. A redacted value answers no better.
def in_scope?(values, packed_id, withholding, id_answers_for_keys: true)

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): in_scope? [qlty:function-parameters]

# that may not be the one its values were true under — a state reconstruction, which can sit on the far
# side of a primary-key move it cannot see. A key never captured at all is still filled: read-only, so
# it cannot have moved.
def answerable_snapshot(values, packed_id, collection, id_answers_for_keys: true)

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): answerable_snapshot [qlty:function-parameters]

@qltysh

qltysh Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

Qlty


Coverage Impact

This PR will not change total coverage.

Modified Files with Diff Coverage (5)

RatingFile% DiffUncovered Line #s
Coverage rating: A Coverage rating: A
...forest_admin_agent/routes/resources/audit_trail_correlation.rb100.0%
Coverage rating: A Coverage rating: A
...t/lib/forest_admin_agent/routes/resources/audit_trail_route.rb100.0%
Coverage rating: A Coverage rating: A
...forest_admin_agent/lib/forest_admin_agent/audit_trail/store.rb100.0%
Coverage rating: A Coverage rating: A
...n_agent/lib/forest_admin_agent/routes/resources/audit_trail.rb100.0%
New file Coverage rating: A
...forest_admin_agent/routes/resources/audit_trail_withholding.rb100.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.

The first check ran before the audit rows were read, so its answer was
already stale by the time anything was withheld. Taking it as a fallback —
`scope ||= scope_if_gone_since(...)` — meant an id that was gone at the
check and taken by another record before the rows came back served that
record's history to a caller with no claim on it, where a request starting
a moment later answers 404.

The first check stays for the 404 it raises before the audit database is
touched, and its answer is now discarded. All three routes read again and
act on that, rather than only when the first check came back empty.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@bexchauveto
bexchauveto force-pushed the feature/prd-1295-audit-trail-state-and-the-correlation-routes-bypass-the branch from a7fafb1 to 3a0b718 Compare September 25, 2026 12:22
…e life before it

Taking the second read as the only answer let it clear the withholding the
first had established: an id freed by a delete and taken, before the rows
came back, by a record the caller *can* read reported "present and in
scope", and the dead record's captured values went out unwithheld.

Both reads count now and neither cancels the other. The first is the only
one that saw the record as it was while the rows were being chosen; the
second is the only one that can see a record deleted since, and the only
one that can refuse an id now held by a record this caller cannot read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# These routes serve the same rows as the per-record history route, so a gone record's captured values
# are tested against the caller's scope here too — otherwise what that route withholds comes back
# through a correlation lookup.
def withhold_gone_record(history, context, collection, record_id, gone_at_check)

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): withhold_gone_record [qlty:function-parameters]

# taken by another record since — answers for itself, never for the life whose rows these are. The
# second is the only one that can see a record deleted since, and it is where the 404 comes from when
# that replacement is one this caller cannot read.
def withholding_scope_for(context, collection, packed_id, gone_at_check)

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): withholding_scope_for [qlty:function-parameters]

@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-1295): conforms. /state takes option 2, both correlation routes withhold row by row, and the predicate is shared in AuditTrailWithholding.

…not the ones withheld [PRD-1295]

Matched in SQL on a gone record's history, a search still answered what the
withholding hides: whether a row came back, the count and the authors each
said whether a withheld value held the term. Under a scope on a record gone at
the check, the rows are read without those two filters, withheld, then matched
and paged in memory.

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

false
end
def history_matched_in_store(context, args, filters, gone_at_check)

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): history_matched_in_store [qlty:function-parameters]

… identity their rows carry

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eeping only the page

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… instant it starts

Offsets over a log still being written to shift between batches, repeating or
skipping rows, and an id taken since could keep an oldest-first scan chasing
new rows. Each batch now continues past the last row read, within an end bound
set when the scan starts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@@ -74,13 +74,15 @@ def discard(ids)
end

def list_by_record(collection:, record_id:, skip: 0, limit: nil, 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 = 11): list_by_record [qlty:function-parameters]

bexchauveto and others added 2 commits September 29, 2026 14:22
…t identity, whatever the sort

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

The second read of the record decides the withholding, so the count,
authors and page follow it too instead of the SQL match.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# count and the authors would still say what the withholding hides — one probe per character. They are
# matched against what is served instead, which means scanning the whole history here, in batches, so
# only the page asked for is kept. Only for a gone record: one in scope was the caller's to read whole.
def history_matched_after_withholding(context, args, filters, gone_at_check, withholding = 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 = 5): history_matched_after_withholding [qlty:function-parameters]

# Each batch continues past the last row read rather than at an offset, which entries written between
# batches would shift. Bounded at the instant the scan starts too, so an id taken by another record
# since cannot keep an oldest-first scan chasing its new rows.
def each_withheld_batch(context, args, filters, gone_at_check, withholding, &block)

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): each_withheld_batch [qlty:function-parameters]

…are a timestamp [PRD-1295]

The store breaks timestamp ties by id ascending in both directions, so
the first row read newest first is not always the latest one. Keep the
row with the greatest (timestamp, id) instead of relying on read order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
latest[entry.user_id] = entry if newer?(entry, latest[entry.user_id])
end

[page, count, -> { latest.values.map { |entry| author_of(entry) } }]

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 = 12): history_matched_after_withholding [qlty:function-complexity]

@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-1295): conforms. History, /state and both correlation routes withhold, and /state answers null when the reconstruction fails the scope, per option 2.

…he database would not [PRD-1295]

In memory `status != 'private'` holds for a nil status, while the scoped read
that guarded the live record left it out: a record the caller could never read
alive became readable once deleted. NOT_EQUAL, NOT_IN and NOT_CONTAINS now
never match a nil, leaf by leaf, so a scope asking for the nil itself still
does. Specs also cover the redaction mask on the served-value scan and assert
what the mid-request re-check asks for.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
end
return false if snapshot[tree.field].nil? && NULL_EXCLUDING_OPERATORS.include?(tree.operator)

tree.match(snapshot, withholding.collection, withholding.timezone)

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 = 6): matches_as_stored? [qlty:function-complexity]

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