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
- 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.
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.