<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Steadybyte</title>
    <description>The latest articles on DEV Community by Steadybyte (@steadybyte).</description>
    <link>https://dev.to/steadybyte</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4207180%2F1d7afee6-b93e-4779-a34e-aa609daaff19.png</url>
      <title>DEV Community: Steadybyte</title>
      <link>https://dev.to/steadybyte</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/steadybyte"/>
    <language>en</language>
    <item>
      <title>Your Rails audit log has 4 blind spots. Auditors know all of them.</title>
      <dc:creator>Steadybyte</dc:creator>
      <pubDate>Sun, 25 Oct 2026 18:48:43 +0000</pubDate>
      <link>https://dev.to/steadybyte/your-rails-audit-log-answers-what-changed-auditors-ask-four-more-questions-3n4o</link>
      <guid>https://dev.to/steadybyte/your-rails-audit-log-answers-what-changed-auditors-ask-four-more-questions-3n4o</guid>
      <description>&lt;p&gt;&lt;em&gt;By the Steadybyte team&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Search for "Rails audit log" and you'll find good answers: &lt;a href="https://github.com/paper-trail-gem/paper_trail" rel="noopener noreferrer"&gt;paper_trail&lt;/a&gt;, &lt;a href="https://github.com/collectiveidea/audited" rel="noopener noreferrer"&gt;audited&lt;/a&gt;, &lt;a href="https://github.com/palkan/logidze" rel="noopener noreferrer"&gt;logidze&lt;/a&gt;, and many tutorials that add an &lt;code&gt;after_save&lt;/code&gt; callback and an &lt;code&gt;audits&lt;/code&gt; table. They all answer one question well: &lt;strong&gt;what changed, and who changed it?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That's often enough until your first HIPAA risk assessment, SOC 2 audit or enterprise security questionnaire. Then the questions get wider. This post walks through those questions, shows where change tracking stops, and explains how to close each gap with plain Rails and PostgreSQL. At the end we show the gem we're building for this, but everything before that section works without it.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Not legal advice.&lt;/strong&gt; This post is about engineering, not law. Which rules apply to you, and what counts as enough evidence, is for your compliance team and your auditor to decide.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  A quick glossary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;PHI (protected health information):&lt;/strong&gt; under HIPAA, health information that can be tied to a person, such as a diagnosis on a patient record.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HIPAA audit controls:&lt;/strong&gt; &lt;a href="https://www.law.cornell.edu/cfr/text/45/164.312" rel="noopener noreferrer"&gt;45 CFR §164.312(b)&lt;/a&gt; requires "hardware, software, and/or procedural mechanisms that record and examine activity in information systems that contain or use electronic protected health information." It doesn't list which fields to log. A related rule, &lt;a href="https://www.law.cornell.edu/cfr/text/45/164.308" rel="noopener noreferrer"&gt;§164.308(a)(1)(ii)(D)&lt;/a&gt;, asks you to "regularly review records of information system activity, such as audit logs, access reports, and security incident tracking reports."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SOC 2:&lt;/strong&gt; an audit report based on the AICPA's Trust Services Criteria. Audit logs usually come up under the common criteria for logical and physical access controls (CC6) and system operations, which include monitoring (CC7). Auditors ask for &lt;em&gt;evidence&lt;/em&gt;, not just a description.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Evidence:&lt;/strong&gt; something you can hand an auditor, such as an export, a report or a screenshot, that shows a control actually operated.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The five questions
&lt;/h2&gt;

&lt;p&gt;Here are the questions an auditor or a customer's security team commonly asks about an audit trail:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Who changed this record, and what did it look like before?&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Who &lt;em&gt;viewed&lt;/em&gt; or &lt;em&gt;exported&lt;/em&gt; this sensitive data?&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;How do you know nobody edited or deleted the log?&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;How long do you keep it, is that enforced, and how does it fit with deletion requests?&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Can you show me?&lt;/strong&gt; For example, who accessed patient X last quarter, and what did admins do?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Change-tracking gems are built for question 1. Teams often end up answering the other four by hand, shortly before the audit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Question 1: what changed (the part that's already solved)
&lt;/h2&gt;

&lt;p&gt;The existing tools deserve proper credit here, because they do this well:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;paper_trail&lt;/strong&gt; writes a row to a &lt;code&gt;versions&lt;/code&gt; table on create, update, destroy and touch. The row stores the record's previous state, and optionally the diff in an &lt;code&gt;object_changes&lt;/code&gt; column. The &lt;code&gt;whodunnit&lt;/code&gt; column holds the user, set by a &lt;code&gt;set_paper_trail_whodunnit&lt;/code&gt; controller callback. You can &lt;code&gt;reify&lt;/code&gt; any version to see or restore the record as it was.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;audited&lt;/strong&gt; writes to an &lt;code&gt;audits&lt;/code&gt; table with the changed values, the current user, a version number and an optional comment. In controller requests it also stores the remote IP and a request UUID. It can link audits to a parent record (&lt;code&gt;associated_with&lt;/code&gt;) and rebuild old versions (&lt;code&gt;revision&lt;/code&gt;, &lt;code&gt;revision_at&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;logidze&lt;/strong&gt; (PostgreSQL only) uses database triggers to keep a record's history in a &lt;code&gt;log_data&lt;/code&gt; JSONB column, on the same row by default. Because it works in the database, bulk updates such as &lt;code&gt;update_all&lt;/code&gt; are logged too. Its README is clear that it is "designed to only track changes": when the row is deleted, its history goes with it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If what you need is undo, a history view or debugging, these are excellent choices. None of the gaps below are bugs in these gems. They're simply outside what the gems were built to do.&lt;/p&gt;

&lt;h2&gt;
  
  
  Question 2: who viewed or exported it?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why change tracking can't answer it:&lt;/strong&gt; these gems record writes. When a support agent opens a patient's page, downloads a CSV of 4,000 customers, or an API client fetches &lt;code&gt;/patients/42&lt;/code&gt;, nothing is written, so nothing is recorded.&lt;/p&gt;

&lt;p&gt;With PHI and other personal data, reads are often what matters most. The worrying case is usually not someone changing a diagnosis. It's someone &lt;em&gt;looking&lt;/em&gt; at records they had no reason to see.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Closing the gap yourself.&lt;/strong&gt; Use a separate, narrow table for access events. Don't mix reads into your versions table: there are far more reads than writes, and they would bury the change history.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# db/migrate/xxxx_create_access_events.rb&lt;/span&gt;
&lt;span class="n"&gt;create_table&lt;/span&gt; &lt;span class="ss"&gt;:access_events&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
  &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;   &lt;span class="ss"&gt;:actor_type&lt;/span&gt;
  &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;   &lt;span class="ss"&gt;:actor_id&lt;/span&gt;
  &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;   &lt;span class="ss"&gt;:action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;null: &lt;/span&gt;&lt;span class="kp"&gt;false&lt;/span&gt;        &lt;span class="c1"&gt;# "show", "index", "export"&lt;/span&gt;
  &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;   &lt;span class="ss"&gt;:resource_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;null: &lt;/span&gt;&lt;span class="kp"&gt;false&lt;/span&gt;
  &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;     &lt;span class="ss"&gt;:resource_ids&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;null: &lt;/span&gt;&lt;span class="kp"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;default: &lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;
  &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;integer&lt;/span&gt;  &lt;span class="ss"&gt;:record_count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;null: &lt;/span&gt;&lt;span class="kp"&gt;false&lt;/span&gt;
  &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;   &lt;span class="ss"&gt;:request_id&lt;/span&gt;
  &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;   &lt;span class="ss"&gt;:remote_ip&lt;/span&gt;
  &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;datetime&lt;/span&gt; &lt;span class="ss"&gt;:created_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;null: &lt;/span&gt;&lt;span class="kp"&gt;false&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="n"&gt;add_index&lt;/span&gt; &lt;span class="ss"&gt;:access_events&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:resource_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:created_at&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then add a controller concern that each action calls explicitly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# app/controllers/concerns/logs_access.rb&lt;/span&gt;
&lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="nn"&gt;LogsAccess&lt;/span&gt;
  &lt;span class="kp"&gt;extend&lt;/span&gt; &lt;span class="no"&gt;ActiveSupport&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Concern&lt;/span&gt;

  &lt;span class="kp"&gt;private&lt;/span&gt;

  &lt;span class="c1"&gt;# Call this after authorization has passed, just before the data goes out.&lt;/span&gt;
  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;log_access&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;records&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;resource_type: &lt;/span&gt;&lt;span class="kp"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;records&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;records&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="no"&gt;AccessEvent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="ss"&gt;actor_type: &lt;/span&gt;&lt;span class="n"&gt;current_user&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;class&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;actor_id: &lt;/span&gt;&lt;span class="n"&gt;current_user&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;action: &lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;resource_type: &lt;/span&gt;&lt;span class="n"&gt;resource_type&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;records&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;class&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;base_class&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;name&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;controller_name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;classify&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;resource_ids: &lt;/span&gt;&lt;span class="n"&gt;records&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="ss"&gt;:id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="c1"&gt;# cap it; record_count has the total&lt;/span&gt;
      &lt;span class="ss"&gt;record_count: &lt;/span&gt;&lt;span class="n"&gt;records&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;request_id: &lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;request_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;remote_ip: &lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;remote_ip&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="ss"&gt;created_at: &lt;/span&gt;&lt;span class="no"&gt;Time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;current&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PatientsController&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ApplicationController&lt;/span&gt;
  &lt;span class="kp"&gt;include&lt;/span&gt; &lt;span class="no"&gt;LogsAccess&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;show&lt;/span&gt;
    &lt;span class="vi"&gt;@patient&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Patient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:id&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="c1"&gt;# after your authorization check&lt;/span&gt;
    &lt;span class="n"&gt;log_access&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"show"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="vi"&gt;@patient&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;export&lt;/span&gt;
    &lt;span class="n"&gt;patients&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Patient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;clinic_id: &lt;/span&gt;&lt;span class="n"&gt;current_user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clinic_id&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to_a&lt;/span&gt;
    &lt;span class="n"&gt;log_access&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"export"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;patients&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;resource_type: &lt;/span&gt;&lt;span class="s2"&gt;"Patient"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;send_data&lt;/span&gt; &lt;span class="no"&gt;PatientCsv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;patients&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to_csv&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;filename: &lt;/span&gt;&lt;span class="s2"&gt;"patients.csv"&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Things to watch for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Log at the point of disclosure:&lt;/strong&gt; after authorization, just before the data leaves. A &lt;code&gt;before_action&lt;/code&gt; would also log requests that later fail.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pay most attention to exports.&lt;/strong&gt; One CSV can expose thousands of records, so record how many rows left and which ones.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cover the other ways data leaves:&lt;/strong&gt; JSON APIs, GraphQL resolvers, Active Storage downloads, admin panels, mailers that include PHI, and the Rails console.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Decide what happens if the log write fails.&lt;/strong&gt; &lt;code&gt;create!&lt;/code&gt; raises, so the response fails and no data goes out without a log entry. For the most sensitive data that's usually the easier choice to defend, but it should be a deliberate decision.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Question 3: can you prove the log wasn't edited?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why an ordinary table falls short:&lt;/strong&gt; an audit table is just a table. Anyone who can write to the database, whether a DBA, a leaked app credential or a buggy migration, can &lt;code&gt;UPDATE&lt;/code&gt; or &lt;code&gt;DELETE&lt;/code&gt; rows without leaving a trace. Marking the model read-only in Active Record stops your &lt;em&gt;application code&lt;/em&gt;, but not SQL.&lt;/p&gt;

&lt;p&gt;Two layers help, and it's worth having both.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 1: append-only permissions.&lt;/strong&gt; Give ownership of the table to a separate role, then let the app's database user insert and read but not change rows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- PostgreSQL. access_events is owned by a separate role (e.g. the one that&lt;/span&gt;
&lt;span class="c1"&gt;-- runs migrations), and the app connects as app_user.&lt;/span&gt;
&lt;span class="k"&gt;REVOKE&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;access_events&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;app_user&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;access_events&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;app_user&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;USAGE&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;SEQUENCE&lt;/span&gt; &lt;span class="n"&gt;access_events_id_seq&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;app_user&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The ownership part is important: a table's owner can always grant itself rights back. Run deletions for retention (below) under a separate, more privileged role used only by that job.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 2: make tampering detectable with a hash chain.&lt;/strong&gt; Each entry stores a hash of its own contents &lt;em&gt;plus the previous entry's hash&lt;/em&gt;. If someone edits an old row, its hash no longer matches. If they delete a row, the next row's &lt;code&gt;prev_digest&lt;/code&gt; points to a hash that isn't there. Either way, a verify pass catches it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="nb"&gt;require&lt;/span&gt; &lt;span class="s2"&gt;"openssl"&lt;/span&gt;
&lt;span class="nb"&gt;require&lt;/span&gt; &lt;span class="s2"&gt;"json"&lt;/span&gt;

&lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="nn"&gt;AuditChain&lt;/span&gt;
  &lt;span class="no"&gt;GENESIS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"GENESIS"&lt;/span&gt;

  &lt;span class="kp"&gt;module_function&lt;/span&gt;

  &lt;span class="c1"&gt;# Same content =&amp;gt; same bytes: sort keys at every level.&lt;/span&gt;
  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;canonical&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;
    &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="no"&gt;Hash&lt;/span&gt;  &lt;span class="k"&gt;then&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_h&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;canonical&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="p"&gt;}.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_h&lt;/span&gt;
    &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="no"&gt;Array&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;canonical&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;digest_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prev_digest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="no"&gt;OpenSSL&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Digest&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;SHA256&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;prev_digest&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;|&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="no"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;canonical&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="c1"&gt;# entries: oldest first, each responding to #payload, #prev_digest and #digest.&lt;/span&gt;
  &lt;span class="c1"&gt;# Returns the first entry that breaks the chain, or nil if it is intact.&lt;/span&gt;
  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;start: &lt;/span&gt;&lt;span class="no"&gt;GENESIS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;prev&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt;
    &lt;span class="n"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt; &lt;span class="k"&gt;unless&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;prev_digest&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;prev&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt; &lt;span class="k"&gt;unless&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;digest_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

      &lt;span class="n"&gt;prev&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
    &lt;span class="kp"&gt;nil&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Appending has to be serialized. Otherwise two requests can read the same "previous" hash at once and fork the chain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ChainedEvent&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ApplicationRecord&lt;/span&gt; &lt;span class="c1"&gt;# columns: payload (json), prev_digest, digest&lt;/span&gt;
  &lt;span class="no"&gt;CHAIN_LOCK_KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4242&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nc"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;transaction&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
      &lt;span class="c1"&gt;# PostgreSQL: one writer at a time; the lock is released at COMMIT/ROLLBACK.&lt;/span&gt;
      &lt;span class="n"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"SELECT pg_advisory_xact_lock(&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="no"&gt;CHAIN_LOCK_KEY&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;)"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="n"&gt;prev&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:id&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;last&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="no"&gt;AuditChain&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;GENESIS&lt;/span&gt;
      &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;AuditChain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;canonical&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;merge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;at: &lt;/span&gt;&lt;span class="no"&gt;Time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;iso8601&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
      &lt;span class="n"&gt;create!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;payload: &lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;prev_digest: &lt;/span&gt;&lt;span class="n"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;digest: &lt;/span&gt;&lt;span class="no"&gt;AuditChain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;digest_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prev&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="no"&gt;AuditChain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;ChainedEvent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:id&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;find_each&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# =&amp;gt; nil, or the first bad row&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What tutorials usually leave out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Contention.&lt;/strong&gt; A single global lock caps your write rate. Separate chains per tenant or per day spread the load.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Canonical serialization.&lt;/strong&gt; Store values in the exact form you hashed: timestamps as fixed-precision strings, sorted keys. That way a JSON round trip, or a Rails upgrade, doesn't change the bytes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anchoring.&lt;/strong&gt; Someone with full database access could rewrite the &lt;em&gt;whole&lt;/em&gt; chain. Regularly copy the latest digest somewhere they can't reach, such as WORM storage (for example S3 Object Lock), a separate account, or a signed message, and verify against those copies.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run verify on a schedule and alert on failure.&lt;/strong&gt; A check nobody runs isn't a control.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Question 4: retention, and deletion requests
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why it gets awkward:&lt;/strong&gt; change and access logs grow without limit unless you prune them. Two pressures pull in opposite directions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Keep it long enough.&lt;/strong&gt; HIPAA requires documentation required by the Security Rule to be kept for six years (&lt;a href="https://www.law.cornell.edu/cfr/text/45/164.316" rel="noopener noreferrer"&gt;§164.316(b)(2)(i)&lt;/a&gt;). Many teams apply that period to audit logs too. How it applies to your logs is a question for your compliance advisor. India's DPDP Rules, 2025 (Rule 6, which is being phased in) call for logs giving "visibility on the accessing of such personal data", kept for one year unless another law requires otherwise. Customer contracts often add their own periods.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't keep personal data forever.&lt;/strong&gt; GDPR's storage limitation principle and right to erasure (Articles 5(1)(e) and 17), and India's DPDP Act, push you to delete personal data you no longer need.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The gems offer limits based on count rather than time: paper_trail's &lt;code&gt;version_limit&lt;/code&gt;, audited's &lt;code&gt;max_audits&lt;/code&gt;, and logidze's log size &lt;code&gt;limit&lt;/code&gt;. paper_trail's README also shows how to delete old versions by date yourself. For a policy based on time, a nightly job works:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Run with the privileged "retention" database role.&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AuditRetentionJob&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ApplicationJob&lt;/span&gt;
  &lt;span class="no"&gt;POLICIES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="no"&gt;AccessEvent&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;years&lt;/span&gt; &lt;span class="p"&gt;}.&lt;/span&gt;&lt;span class="nf"&gt;freeze&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;perform&lt;/span&gt;
    &lt;span class="no"&gt;POLICIES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;period&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
      &lt;span class="n"&gt;expired&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;created_at: &lt;/span&gt;&lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="n"&gt;period&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ago&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="n"&gt;archive!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expired&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="n"&gt;expired&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;in_batches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;of: &lt;/span&gt;&lt;span class="mi"&gt;10_000&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;delete_all&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="kp"&gt;private&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;archive!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;relation&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;# Your code: e.g. write relation.find_each rows as compressed JSON lines&lt;/span&gt;
    &lt;span class="c1"&gt;# to an S3 bucket with Object Lock, before they are deleted.&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the table is hash-chained, deleting the oldest rows makes verify fail where the chain now begins. Save a &lt;strong&gt;checkpoint&lt;/strong&gt; instead: the digest of the last deleted row. Verify from there with &lt;code&gt;AuditChain.verify(remaining, start: checkpoint_digest)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Erasure is harder.&lt;/strong&gt; Deleting a person's entries breaks the chain and destroys evidence you may need to keep. Common ways around this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Don't put raw personal data in the log at all.&lt;/strong&gt; Store a keyed fingerprint (HMAC) or a masked value instead. You can still search by fingerprint, but the log never holds the value.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hash each field, then chain the field hashes.&lt;/strong&gt; To erase, replace a value with a marker but keep its field hash. The chain still verifies, and the personal data is gone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Crypto-shredding.&lt;/strong&gt; Encrypt each person's data with their own key, and delete the key when they ask to be erased.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Question 5: can you show me?
&lt;/h2&gt;

&lt;p&gt;Auditors want artifacts. In practice that means things like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Per-subject access history:&lt;/strong&gt; everyone who viewed or changed patient 42 between January and March.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Access review:&lt;/strong&gt; who accessed sensitive data, how often, and whether that fits their role.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Privileged activity:&lt;/strong&gt; what admins and support staff did, including bulk operations and console work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Integrity evidence:&lt;/strong&gt; the result of the latest chain verification, with dates.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Once the data above exists, these are ordinary queries. The hard part is collecting that data reliably, in a consistent shape, before anyone asks for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting it together
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;paper_trail&lt;/th&gt;
&lt;th&gt;audited&lt;/th&gt;
&lt;th&gt;logidze&lt;/th&gt;
&lt;th&gt;DIY tutorial&lt;/th&gt;
&lt;th&gt;SealedTrail (free)&lt;/th&gt;
&lt;th&gt;SealedTrail Pro (early access)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Change history&lt;/td&gt;
&lt;td&gt;✅ create, update, destroy, touch&lt;/td&gt;
&lt;td&gt;✅ create, update, destroy, touch&lt;/td&gt;
&lt;td&gt;Inserts and updates; history is lost when the row is deleted&lt;/td&gt;
&lt;td&gt;✅ usually&lt;/td&gt;
&lt;td&gt;✅ create, update, destroy (touch opt-in)&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;View or restore old versions&lt;/td&gt;
&lt;td&gt;✅ &lt;code&gt;reify&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;✅ &lt;code&gt;revision&lt;/code&gt;, &lt;code&gt;revision_at&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;✅ &lt;code&gt;at&lt;/code&gt;, &lt;code&gt;undo!&lt;/code&gt;, &lt;code&gt;redo!&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Rarely&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌ not planned&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Who made the change&lt;/td&gt;
&lt;td&gt;✅ &lt;code&gt;whodunnit&lt;/code&gt; via controller callback&lt;/td&gt;
&lt;td&gt;✅ &lt;code&gt;current_user&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Via &lt;code&gt;with_responsible&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Varies&lt;/td&gt;
&lt;td&gt;✅ &lt;code&gt;current_user&lt;/code&gt;, with fallback labels&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Request ID and IP&lt;/td&gt;
&lt;td&gt;Via &lt;code&gt;info_for_paper_trail&lt;/code&gt; metadata and your own columns&lt;/td&gt;
&lt;td&gt;✅ in controller requests&lt;/td&gt;
&lt;td&gt;Via &lt;code&gt;with_meta&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Varies&lt;/td&gt;
&lt;td&gt;✅ in requests&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Background jobs inherit the user&lt;/td&gt;
&lt;td&gt;Not built in&lt;/td&gt;
&lt;td&gt;Not built in&lt;/td&gt;
&lt;td&gt;Not built in&lt;/td&gt;
&lt;td&gt;Rarely&lt;/td&gt;
&lt;td&gt;✅ Active Job&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;update_all&lt;/code&gt; / &lt;code&gt;delete_all&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;❌ not recorded&lt;/td&gt;
&lt;td&gt;❌ not recorded&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;update_all&lt;/code&gt; logged by the trigger&lt;/td&gt;
&lt;td&gt;❌ usually&lt;/td&gt;
&lt;td&gt;✅ one summary entry each&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sensitive values&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;skip:&lt;/code&gt; per attribute&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;redacted:&lt;/code&gt; option; &lt;code&gt;encrypts&lt;/code&gt; attributes filtered&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;only&lt;/code&gt; / &lt;code&gt;except&lt;/code&gt; columns&lt;/td&gt;
&lt;td&gt;Manual&lt;/td&gt;
&lt;td&gt;Redacted by default (common names and &lt;code&gt;encrypts&lt;/code&gt;), plus mask and fingerprint options&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reads and exports logged&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;If you build it&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;Planned&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tamper-evident chain and verify task&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;If you build it&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;Planned&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Retention&lt;/td&gt;
&lt;td&gt;Count limit per record&lt;/td&gt;
&lt;td&gt;Count limit per record&lt;/td&gt;
&lt;td&gt;Log size limit per record&lt;/td&gt;
&lt;td&gt;If you build it&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;Planned: time-based, with erasure&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Auditor reports&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;If you build it&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;Planned&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Data stays in your own database&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Based on each project's README and source as of October 2026 (paper_trail 17.0.0 and audited 5.8.0 for the &lt;code&gt;update_all&lt;/code&gt;/&lt;code&gt;delete_all&lt;/code&gt; row, which we checked by running them). Check their docs for the latest. If you need version restore, use paper_trail, audited or logidze. SealedTrail runs alongside paper_trail and audited.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Where SealedTrail fits
&lt;/h2&gt;

&lt;p&gt;We're building &lt;a href="https://github.com/steadybytehq/sealed_trail" rel="noopener noreferrer"&gt;SealedTrail&lt;/a&gt; for the questions above. It's open core.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The free core (MIT, available now)&lt;/strong&gt; handles question 1, plus details that tend to come up in audits. Every entry has an actor, with the request ID and IP for changes made in a request. Active Job jobs inherit the user who enqueued them. Bulk operations are recorded, and sensitive values are redacted by default.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Gemfile&lt;/span&gt;
&lt;span class="n"&gt;gem&lt;/span&gt; &lt;span class="s2"&gt;"sealed_trail"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bundle &lt;span class="nb"&gt;install
&lt;/span&gt;bin/rails generate sealed_trail:install
bin/rails db:migrate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Patient&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ApplicationRecord&lt;/span&gt;
  &lt;span class="n"&gt;has_sealed_trail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="ss"&gt;ignore: &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:last_seen_at&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="ss"&gt;redact: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;ssn: :mask&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;insurance_id: :fingerprint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;diagnosis: :redact&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="ss"&gt;meta: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;clinic_id: :clinic_id&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="c1"&gt;# In controllers the actor is current_user. Elsewhere, set it explicitly:&lt;/span&gt;
&lt;span class="no"&gt;SealedTrail&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;with_actor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"nightly_import"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="no"&gt;Importer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="no"&gt;SealedTrail&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;with_metadata&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;reason: &lt;/span&gt;&lt;span class="s2"&gt;"Support ticket #4521"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="n"&gt;patient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;status: &lt;/span&gt;&lt;span class="s2"&gt;"discharged"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="c1"&gt;# Bulk operations get one summary entry, with the affected ids (capped):&lt;/span&gt;
&lt;span class="no"&gt;Patient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;clinic_id: &lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;update_all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;status: &lt;/span&gt;&lt;span class="s2"&gt;"archived"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="no"&gt;SealedTrail&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bulk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;last&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bulk_summary&lt;/span&gt;
&lt;span class="c1"&gt;# =&amp;gt; {"operation"=&amp;gt;"update_all", "row_count"=&amp;gt;42, "ids"=&amp;gt;[...], "ids_truncated"=&amp;gt;false, "where_sql"=&amp;gt;"..."}&lt;/span&gt;

&lt;span class="c1"&gt;# Querying&lt;/span&gt;
&lt;span class="no"&gt;SealedTrail&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;for_item_type&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;Patient&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;events&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:update&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;for_actor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;between&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;month&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ago&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kp"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="no"&gt;SealedTrail&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;with_fingerprint&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"INS-12345"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# entries whose insurance_id fingerprint matches&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The free core has limits too. Entries are read-only &lt;em&gt;through Active Record&lt;/em&gt;, but that isn't tamper evidence. Hand-written SQL (&lt;code&gt;connection.execute&lt;/code&gt;) isn't captured, and reads aren't logged. The &lt;a href="https://github.com/steadybytehq/sealed_trail#limitations" rel="noopener noreferrer"&gt;README&lt;/a&gt; lists all of this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SealedTrail Pro (early access, not released yet)&lt;/strong&gt; is aimed at questions 2–5. A beta for founding customers is planned for December 2026, and v1.0 by 31 January 2027. The API below is &lt;strong&gt;planned and may change&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# PLANNED Pro API: illustrative, not available yet&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Patient&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ApplicationRecord&lt;/span&gt;
  &lt;span class="n"&gt;has_sealed_trail&lt;/span&gt; &lt;span class="ss"&gt;track_reads: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;retain_for: &lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;years&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="no"&gt;SealedTrail&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;access_report&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;record: &lt;/span&gt;&lt;span class="n"&gt;patient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;since: &lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;days&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ago&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# who viewed or changed it&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# PLANNED&lt;/span&gt;
bin/rails sealed_trail:verify   &lt;span class="c"&gt;# walks the hash chain, reports edited or missing entries&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Planned Pro features:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;read and export logging&lt;/li&gt;
&lt;li&gt;a SHA-256 hash chain with append-only permissions and a verify task&lt;/li&gt;
&lt;li&gt;retention per model, with erasure that keeps the chain verifiable&lt;/li&gt;
&lt;li&gt;auditor reports: access reviews, privileged activity and per-subject history&lt;/li&gt;
&lt;li&gt;import from paper_trail and audited&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything stays in your own database, so there's no SaaS and no per-event pricing.&lt;/p&gt;

&lt;p&gt;SealedTrail helps you &lt;em&gt;produce evidence&lt;/em&gt;. It doesn't make an app HIPAA- or SOC 2-compliant by itself. That depends on your whole system, your policies and your auditor.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it, or tell us what we're missing
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Install the free gem with &lt;code&gt;bundle add sealed_trail&lt;/code&gt; (&lt;a href="https://rubygems.org/gems/sealed_trail" rel="noopener noreferrer"&gt;RubyGems&lt;/a&gt;, &lt;a href="https://github.com/steadybytehq/sealed_trail" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;Pro early access and founding pricing: &lt;a href="https://steadybytehq.github.io/sealedtrail/" rel="noopener noreferrer"&gt;steadybytehq.github.io/sealedtrail&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you've been through a HIPAA or SOC 2 audit on Rails, we'd like to hear which of these questions hurt most, or which one we missed. Leave a comment, open an issue, or email &lt;a href="mailto:steadybyte.hq@gmail.com"&gt;steadybyte.hq@gmail.com&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The Steadybyte team&lt;/em&gt;&lt;/p&gt;

</description>
      <category>rails</category>
      <category>ruby</category>
      <category>security</category>
      <category>hipaa</category>
    </item>
  </channel>
</rss>
