Project

agreements

0.0
The project is in a healthy, maintained state
A small Rails engine that stores immutable external agreement-version metadata and append-only localized acceptance evidence. It distinguishes the subject bound by an agreement from the actor accepting under recorded authority, rejects stale rendered versions, and leaves documents, authentication, authorization, routes, and UI in the host application.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Runtime

>= 7.1, < 9
 Project Readme

agreements

Auditable acceptance of externally hosted legal agreements for Rails.

agreements answers one durable question:

Which agreement version and localized statement did this subject accept, who performed the acceptance, under what authority, and when?

It deliberately does not host legal documents or provide a legal CMS. Your legal or marketing site remains authoritative; the gem stores immutable version metadata and append-only acceptance evidence in your own database.

Install

# Gemfile
gem "agreements"
bundle install
bin/rails generate agreements:install
bin/rails db:migrate

The generator adds two tables and nothing else. There is no route, controller, view, initializer, JavaScript, CSS, or admin dashboard to integrate.

Ruby >= 3.2 · Rails >= 7.1 and < 9 · PostgreSQL and SQLite

Upgrading from 0.1

Version 0.2 adds non-null acceptance_statement and locale columns to agreements_acceptances. Add them in a host migration before upgrading. For existing evidence, copy each version's canonical statement and use an honest locale such as und when the displayed locale is unknown.

Define a version

Agreement versions are deployed data. Create them in a data migration and keep the same registry in your seeds for fresh databases:

Agreements::Version.create!(
  agreement_key: "user_terms",
  version: "2026-08-16",
  acceptance_statement: "I accept the Terms of Service and acknowledge the Privacy Notice.",
  documents: [
    { title: "Terms of Service", url: "https://example.com/terms" },
    { title: "Privacy Notice", url: "https://example.com/privacy" }
  ]
)

The newest inserted row for an agreement key is current immediately. There is no draft, publish, activate, or scheduling lifecycle. A correction or legal update is a new immutable row with a new version label.

Document references accept title, an HTTPS url, and an optional 64-character sha256. The gem validates a supplied digest but does not fetch remote pages, calculate hashes, or retain document bytes. Hashing and archival belong in the trusted legal-document publishing process.

Ask what is pending

version = Agreements.current_version("user_terms")
pending = Agreements.pending_version("user_terms", subject: current_user)

Subjects and actors may be records responding to to_global_id, or explicit non-empty opaque strings. The gem stores only the resulting keys; it does not own authentication, tenancy, roles, or authorization.

Render the host-owned form

Keep the page, routes, authorization, document-link markup, and copy in your application. Submit the exact displayed version as a hidden field:

<%= form.hidden_field :agreement_version_id, value: @version.id %>
<%= form.check_box :confirmed, required: true %>

Then record only that version while it remains current:

statement = I18n.t("agreements.user_terms.statement")

acceptance = Agreements.accept!(
  "user_terms",
  version_id: params.dig(:acceptance, :agreement_version_id),
  subject: current_user,
  actor: current_user,
  authority: "self",
  acceptance_statement: statement,
  locale: I18n.locale.to_s
)

Agreements.accept! resolves the current version server-side, requires the submitted ID to match it, derives opaque keys from the server-owned subject and actor, and records the exact localized plain-text statement selected by the host. Do not accept either value from browser parameters. Retries, double clicks, and concurrent submissions return the original acceptance evidence.

A missing, malformed, wrong-agreement, or stale ID raises Agreements::VersionNotCurrent. Its current_version is ready to render:

rescue Agreements::VersionNotCurrent => error
  @version = error.current_version
  render :show, status: :unprocessable_content
end

Authorization stays in the host. For an organization DPA, for example, verify the actor is the current owner before calling:

Agreements.accept!(
  "organization_dpa",
  version_id: params.dig(:acceptance, :agreement_version_id),
  subject: current_organization,
  actor: current_user,
  authority: "organization_owner",
  acceptance_statement: I18n.t("agreements.organization_dpa.statement"),
  locale: I18n.locale.to_s
)

Uniqueness belongs to the organization subject, so a later ownership transfer does not invalidate an existing acceptance.

Enforce an agreement

Include the small controller concern in your application controller:

class ApplicationController < ActionController::Base
  include Agreements::Enforcement
end

Call it from a host-owned before action after authentication and tenant context are established:

def require_organization_dpa
  require_agreement(
    "organization_dpa",
    subject: Current.organization,
    location: organization_dpa_path(Current.organization)
  )
end

It redirects pending HTML/Turbo requests and remembers only GET or HEAD return locations. A blocked mutation is never replayed after acceptance. Consume the safe same-origin path after a successful acceptance:

redirect_to agreement_return_location || dashboard_path

API and headless behavior remains host-owned: use pending_version and return the response contract your application supports.

Evidence model

agreements_versions contains:

  • agreement key;
  • human version label;
  • exact acceptance statement;
  • external document references and optional SHA-256 digests;
  • timestamps.

agreements_acceptances contains:

  • agreement-version foreign key;
  • opaque subject and actor keys;
  • authority;
  • exact localized acceptance statement and locale;
  • server acceptance time;
  • timestamps.

Persisted versions and acceptances are read-only through the model API. The gem does not claim protection from a privileged database owner.

For an exceptional audit request, query ordinary Active Record rows:

Agreements::Acceptance
  .includes(:agreement_version)
  .where(subject_key: current_organization.to_global_id.to_s)

Build an export only when a real audit defines the required format and access controls.

Shipping version two

  1. Publish immutable/versioned legal-document URLs and any verified digests.
  2. Add the finished bundle to the host's seed registry.
  3. Add a host data migration that inserts the same immutable version.
  4. Deploy. The new row becomes current and subjects missing it are prompted.

Production deployments normally run migrations, not seeds. Changing a seed registry alone does not deploy a new version to an existing installation.

Not this gem

  • Legal-document hosting, editing, rendering, uploads, or a CMS.
  • Draft/publish/activation workflows or an admin dashboard.
  • Electronic signatures or identity proofing.
  • IP addresses, user agents, fingerprints, geolocation, or request provenance.
  • Presentation manifests, browser snapshots, scroll tracking, or one-time nonces.
  • Consent withdrawal, declarations, attestations, authorizations, retention, legal holds, integrity chains, or compliance reporting.
  • Application-specific onboarding, marketing consent, tenancy, ownership, layouts, routes, or copy.

Those are real product categories. They are not prerequisites for proving an ordinary Terms or organization-DPA acceptance.

Later, only after a concrete trigger

  • Atomic protected actions: if evidence must accompany a payout, data handoff, or contract execution, commit the evidence and action in one transaction.
  • Standalone receipts: if counsel or an auditor requests a repeatable package, export the smallest proven evidence set as canonical JSON or HTML.
  • Automated digest verification: if exact bytes must be reproduced, verify and archive them in the trusted document-publishing pipeline, not during a user's acceptance request.

License

MIT.