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.
# Gemfile
gem "agreements"bundle install
bin/rails generate agreements:install
bin/rails db:migrateThe 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
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.
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.
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.
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
endAuthorization 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.
Include the small controller concern in your application controller:
class ApplicationController < ActionController::Base
include Agreements::Enforcement
endCall 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)
)
endIt 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_pathAPI and headless behavior remains host-owned: use pending_version and return
the response contract your application supports.
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.
- Publish immutable/versioned legal-document URLs and any verified digests.
- Add the finished bundle to the host's seed registry.
- Add a host data migration that inserts the same immutable version.
- 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.
- 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.
- 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.
MIT.