Tie every acknowledgment to the exact version someone read

  • security
  • databases
  • laravel

I’ve been building a small side project where a manager publishes a written policy and everyone on the team clicks “I have read and understand this.” It sounds like the easiest feature in the app: a button, a row in a table, a timestamp. It is easy to build. It is also easy to build in a way that proves nothing.

The question an acknowledgment has to answer, months later, is not “did Sam click the button?” It is “what exactly did Sam agree to?” If the policy can be edited after Sam clicked, the honest answer is “whatever it says now, probably,” and that is not an answer anyone wants to give an auditor.

The naive version

The first draft most of us would write looks like this:

// policies: id, title, body, updated_at
// acknowledgments: id, policy_id, user_id, created_at
Acknowledgment::create(['policy_id' => $policy->id, 'user_id' => $user->id]);

The acknowledgment points at a row that can change. Fix a typo, tighten a rule, swap the attached PDF, and every old acknowledgment silently starts pointing at the new text. You can’t tell the difference between “agreed to version 3” and “agreed to version 1, which we’ve since rewritten twice.”

Versions are rows, not edits

The fix is to stop editing policies at all. Publishing a change creates a new policy_versions row with an incrementing version number, and acknowledgments point at a version, not at “the policy.”

Two details matter here:

  • Version numbers need a lock. “Read the max version, add one, insert” is a race if two people publish at once. I take a row lock on the parent (the organization) inside the transaction, and back it with a unique index on (organization_id, version) so a race that slips past the lock fails loudly instead of creating two version 4s.
  • Old versions stay readable. The current version is just the newest row. Nothing gets overwritten, so showing someone the exact text they acknowledged is a plain lookup.

Hash what the person actually saw

A version id tells you which row someone acknowledged. A content hash lets you check that the row still says what it said then. At publish time I compute a SHA-256 over the parts that make up the policy and store it on the version. When someone acknowledges, I copy that hash onto their acknowledgment row.

The part that’s easy to miss: if the policy can include an uploaded PDF, the PDF bytes are part of what they read. Hash the file and fold that hash into the policy hash, or someone can replace the attachment without touching the text and every hash still matches.

$content = [
    'version'      => $version,
    'title'        => $data['title'],
    'body'         => $data['body'] ?? '',
    'effective_on' => Carbon::parse($data['effective_on'])->toDateString(),
];

if ($pdf) {
    $content['document_sha256'] = hash('sha256', $pdfBytes);
}

$sha256 = hash('sha256', json_encode($content, JSON_THROW_ON_ERROR));

Notice the date. My first version hashed the raw string from the request. That works until one client sends 2026-10-01 and another sends 2026-10-1, or a later refactor stores the date and re-hashes the formatted value. Hash normalized values in a fixed key order, not whatever came in over the wire, or you’ll get mismatches that look like tampering and aren’t. If the hash ever needs to be recomputed by a different language or service, use a real canonical JSON scheme rather than relying on one language’s encoder.

Make acknowledging safe to double-click

People double-click. Networks retry. The acknowledgment table gets a unique index on (policy_version_id, user_id), and the write is “find or create” inside a transaction, so a second click returns the existing row instead of a second one. I only write the audit event when the row was actually created.

One thing I still need to handle: two truly simultaneous requests can both miss the “find” and one of them hits the unique index. The right behavior there is to catch the duplicate key error and re-fetch, not to show the person a 500 for doing the thing you asked.

Append-only in the app is a guardrail, not a guarantee

In the ORM, the version and acknowledgment models refuse to change:

trait ImmutableRecord
{
    protected static function bootImmutableRecord(): void
    {
        static::updating(fn () => throw new LogicException('This record is append-only.'));
        static::deleting(fn () => throw new LogicException('This record is append-only.'));
    }
}

That catches the honest mistake, like a future feature that tries to “fix” an old version in place. It does not stop much else. Model events don’t fire for query-builder updates, for “save quietly” style helpers, or for raw SQL. Anyone with a database client can still edit a row.

The stronger layers belong in the database:

CREATE TRIGGER policy_versions_no_update
BEFORE UPDATE ON policy_versions
FOR EACH ROW
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'policy_versions is append-only';

Add a matching BEFORE DELETE trigger, and do the same for acknowledgments and the audit log. Then give the app’s runtime database user only SELECT and INSERT on those tables, and run migrations as a separate user. Now even a bug that bypasses the ORM can’t rewrite history through the app’s connection.

Past that, there’s a verification step: a command that recomputes every stored hash and checks that each acknowledgment’s copied hash still matches its version. Any export of the records should run it first and fail closed on a mismatch. If you want to go further, a per-organization hash chain (each audit row includes the previous row’s hash) makes a deleted or edited row in the middle show up as a broken link.

Tradeoffs I accepted

  • More rows. Every typo fix is a new version, and people may need to re-acknowledge. That’s the point, but it’s worth deciding up front which changes require it.
  • Hashes detect changes; they don’t prove origin. Someone with database access can change a row and its hash together. Triggers, narrow grants, and a chain make that harder and more visible. They don’t make it impossible, so I don’t call any of this “tamper-proof,” in the code or in the marketing copy.
  • Storing IP and user agent on the acknowledgment helps answer “was this really them?” It’s also personal data, so it needs the same care as everything else in the table.

The checklist I’ll reuse

  1. Never edit an acknowledged document in place. Publish a new version.
  2. Point acknowledgments at a version id and copy its hash onto the row.
  3. Include attachment bytes in the hash, not just the text.
  4. Hash normalized values, never raw request strings.
  5. Unique index plus find-or-create so retries are harmless.
  6. Enforce append-only in the database with triggers and insert-only grants, and treat the ORM check as a second line.
  7. Verify hashes before you export, and stop if anything doesn’t match.

None of this is specific to policies. Terms of service, consent forms, signed checklists, anything where “they agreed” matters later has the same shape. The click is cheap. Being able to show exactly what was on the screen when it happened is the feature.