diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml
new file mode 100644
index 0000000..6d46489
--- /dev/null
+++ b/.github/workflows/claude.yml
@@ -0,0 +1,41 @@
+name: Claude Code
+
+on:
+ issue_comment:
+ types: [created]
+ pull_request_review_comment:
+ types: [created]
+ issues:
+ types: [opened, assigned]
+ pull_request_review:
+ types: [submitted]
+
+jobs:
+ claude:
+ if: |
+ (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
+ (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
+ (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
+ (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ pull-requests: read
+ issues: read
+ id-token: write
+ actions: read # Required for Claude to read CI results on PRs
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 1
+
+ - name: Run Claude Code
+ id: claude
+ uses: anthropics/claude-code-action@v1
+ with:
+ claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
+
+ # This is an optional setting that allows Claude to read CI results on PRs
+ additional_permissions: |
+ actions: read
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..42271c8
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,181 @@
+name: Tests
+
+on:
+ pull_request:
+ push:
+ branches:
+ - main
+
+jobs:
+ # Static gates: style, autoload health, and a buildable gem — fast,
+ # independent of the database matrix.
+ lint:
+ runs-on: ubuntu-latest
+
+ env:
+ RAILS_ENV: test
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Set up Ruby
+ uses: ruby/setup-ruby@v1
+ with:
+ ruby-version: "3.4"
+ bundler-cache: true
+
+ - name: RuboCop
+ run: bundle exec rubocop
+
+ - name: Prepare dummy database
+ run: bundle exec rake db:create db:migrate
+
+ - name: Zeitwerk eager-load check
+ working-directory: test/dummy
+ run: bundle exec rails zeitwerk:check
+
+ - name: Gem builds
+ run: gem build clickwrap.gemspec
+
+ # Main test suite - tests Ruby versions and Rails compatibility with SQLite
+ sqlite:
+ runs-on: ubuntu-latest
+
+ strategy:
+ fail-fast: false
+ matrix:
+ ruby_version: ["3.2", "3.3", "3.4", "4.0"]
+ gemfile:
+ - Gemfile
+ - gemfiles/rails_7.1.gemfile
+ - gemfiles/rails_7.2.gemfile
+ - gemfiles/rails_8.0.gemfile
+ - gemfiles/rails_8.1.gemfile
+
+ env:
+ RAILS_ENV: test
+ BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }}
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Set up Ruby ${{ matrix.ruby_version }}
+ uses: ruby/setup-ruby@v1
+ with:
+ ruby-version: ${{ matrix.ruby_version }}
+ bundler-cache: true
+
+ - name: Prepare database and run tests
+ # Exercise the real migration path in SQLite too so dummy/test schema
+ # drift is caught in the default matrix, not only adapter-specific jobs.
+ #
+ # Use `db:create db:migrate` rather than `db:migrate:reset`: the reset macro
+ # runs db:drop + db:create + db:migrate IN ONE PROCESS, and on SQLite the
+ # db:migrate step then writes through a stale connection to the just-dropped
+ # file, so nothing persists and the suite boots into "pending migrations"
+ # (PG/MySQL survive it because the DB server reconnects). CI runners start
+ # fresh, so no drop is needed.
+ run: bundle exec rake db:create db:migrate test
+
+ - name: Upload test results
+ if: failure()
+ uses: actions/upload-artifact@v4
+ with:
+ name: test-results-sqlite-ruby-${{ matrix.ruby_version }}-${{ strategy.job-index }}
+ path: test/reports/
+ retention-days: 7
+
+ # PostgreSQL compatibility tests
+ postgres:
+ runs-on: ubuntu-latest
+
+ strategy:
+ fail-fast: false
+ matrix:
+ ruby_version: ["3.4"]
+
+ services:
+ postgres:
+ image: postgres:16
+ env:
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: postgres
+ POSTGRES_DB: clickwrap_test
+ ports:
+ - 5432:5432
+ options: >-
+ --health-cmd="pg_isready -U postgres"
+ --health-interval=10s
+ --health-timeout=5s
+ --health-retries=5
+
+ env:
+ RAILS_ENV: test
+ DATABASE_URL: postgres://postgres:postgres@localhost:5432/clickwrap_test
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Set up Ruby ${{ matrix.ruby_version }}
+ uses: ruby/setup-ruby@v1
+ with:
+ ruby-version: ${{ matrix.ruby_version }}
+ bundler-cache: true
+
+ - name: Prepare database
+ # Use db:migrate:reset instead of db:test:prepare to avoid loading schema.rb
+ # which has SQLite-specific defaults that fail on PostgreSQL.
+ # db:migrate:reset does: db:drop, db:create, db:migrate (using migrations, not schema.rb)
+ run: bundle exec rake db:migrate:reset
+
+ - name: Run tests
+ run: bundle exec rake test
+
+ # MySQL compatibility tests
+ mysql:
+ runs-on: ubuntu-latest
+
+ strategy:
+ fail-fast: false
+ matrix:
+ ruby_version: ["3.4"]
+
+ services:
+ mysql:
+ image: mysql:8.4
+ env:
+ MYSQL_ROOT_PASSWORD: root
+ MYSQL_DATABASE: clickwrap_test
+ ports:
+ - 3306:3306
+ options: >-
+ --health-cmd="mysqladmin ping -h localhost -uroot -proot"
+ --health-interval=10s
+ --health-timeout=5s
+ --health-retries=5
+
+ env:
+ RAILS_ENV: test
+ DATABASE_URL: mysql2://root:root@127.0.0.1:3306/clickwrap_test
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Set up Ruby ${{ matrix.ruby_version }}
+ uses: ruby/setup-ruby@v1
+ with:
+ ruby-version: ${{ matrix.ruby_version }}
+ bundler-cache: true
+
+ - name: Prepare database
+ # Use db:migrate:reset instead of db:test:prepare to avoid loading schema.rb
+ # which has SQLite-specific defaults that fail on MySQL (JSON column defaults).
+ # db:migrate:reset does: db:drop, db:create, db:migrate (using migrations, not schema.rb)
+ run: bundle exec rake db:migrate:reset
+
+ - name: Run tests
+ run: bundle exec rake test
diff --git a/.gitignore b/.gitignore
index bb31b63..b058379 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,36 @@
+/.bundle/
+/.yardoc/
+/_yardoc/
+/coverage/
+/doc/
+/pkg/
+/spec/reports/
+/tmp/
+/dist/
+/log/
+
+# For gems, ignore all lockfiles (root and appraisal) to allow flexible dependency resolution
+# But commit the Appraisal Gemfiles themselves (not the .lock files)
+Gemfile.lock
+gemfiles/*.lock
+
+test/dummy/db/*.sqlite3*
+# A `db:test:prepare` roundtrip can leave an empty schema.rb here, and while it
+# exists `maintain_test_schema!` loads it over the real one and the whole suite
+# dies with "Migrations are pending". The migrations are the source of truth.
+test/dummy/db/schema.rb
+test/dummy/log/*.log*
+test/dummy/tmp/
+test/dummy/storage/
+test/dummy/.bundle/
+
+.DS_Store
+.ruby-lsp/
+
+TODO
+.cursor/
+
+# Owner-directed: the product research corpus stays out of the published
+# repository. See docs/strategy/03-readiness-market-and-next-steps.md.
/docs/
/*.gem
diff --git a/.rubocop.yml b/.rubocop.yml
new file mode 100644
index 0000000..7245426
--- /dev/null
+++ b/.rubocop.yml
@@ -0,0 +1,90 @@
+# MERGE our excludes with rubocop's defaults (vendor/, node_modules/, …) —
+# a bare Exclude key would REPLACE them.
+inherit_mode:
+ merge:
+ - Exclude
+
+AllCops:
+ TargetRubyVersion: 3.2
+ NewCops: enable
+ SuggestExtensions: false
+ Exclude:
+ # The dummy host app mirrors what a real application looks like, including
+ # migrations copied verbatim from the install generator's templates —
+ # don't lint a generated-code mirror against the gem's own style.
+ - test/dummy/**/*
+ # Generator templates are emitted into HOST apps (host style, and some
+ # carry ERB tags rubocop can't parse as Ruby).
+ - lib/generators/clickwrap/templates/**/*
+ # Appraisal-generated Gemfiles.
+ - gemfiles/*
+
+Style/StringLiterals:
+ EnforcedStyle: double_quotes
+
+Style/StringLiteralsInInterpolation:
+ EnforcedStyle: double_quotes
+
+# Hard size metrics fight readable, well-commented domain code and thorough
+# test classes; we optimize for the latter. A policy compiler that explains
+# every refusal in a full sentence is longer than one that raises "invalid",
+# and the long version is the product.
+Metrics:
+ Enabled: false
+
+# Evidence code carries short, standard names for the things it records.
+Naming/MethodParameterName:
+ AllowedNames: [at, by, id, ip, to, ua, key]
+
+# `has_clickwraps` is the macro and the product — it matches the ecosystem
+# grammar (has_sessions, has_credits, has_api_keys), not a predicate.
+Naming/PredicatePrefix:
+ AllowedMethods: [has_clickwraps, has_clickwrap_evidence]
+
+# Receipt and verification predicates read as questions about a recorded fact:
+# `recorded_ip_address?`, `ip_geolocation_was_estimated?`,
+# `browser_user_agent_was_client_supplied?`. Renaming them to satisfy a
+# prefix cop would break the plain-English naming contract these methods exist
+# to honor.
+Naming/PredicateMethod:
+ Enabled: false
+
+# %{policy} / %{document} are I18n interpolation tokens — annotated tokens
+# aren't a thing in I18n templates.
+Style/FormatStringToken:
+ EnforcedStyle: template
+
+# Adapter contracts (IP geolocation resolvers, anchors, timestamp providers)
+# fix keyword names even where a no-op implementation ignores them. The names
+# ARE the documented interface.
+Lint/UnusedMethodArgument:
+ AllowUnusedKeywordArguments: true
+
+Lint/UnusedBlockArgument:
+ AllowUnusedKeywordArguments: true
+
+# The codebase consistently names a rescued exception `error` — it reads as a
+# noun in the sentences these rescue blocks are written as.
+Naming/RescuedExceptionsVariableName:
+ PreferredName: error
+
+# Configuration's readers are grouped by section, matching the order and shape
+# of the generated initializer — which is the main thing a host ever reads
+# about this gem. Collapsing them into one declaration produces a single
+# thousand-character line and loses that structure entirely.
+Style/AccessorGrouping:
+ Exclude:
+ - lib/clickwrap/configuration.rb
+
+# `->(http_request) { http_request.remote_ip }` keeps the parameter name, and
+# the parameter name is the point: the naming contract says `http_request`, not
+# a bare `&:remote_ip` whose receiver is anybody's guess when a host overrides
+# the reader.
+Style/SymbolProc:
+ Exclude:
+ - lib/clickwrap/configuration.rb
+
+Layout/LineLength:
+ Max: 120
+ Exclude:
+ - clickwrap.gemspec # the long-form rubygems description is one line by design
diff --git a/.simplecov b/.simplecov
new file mode 100644
index 0000000..4c31e2f
--- /dev/null
+++ b/.simplecov
@@ -0,0 +1,68 @@
+# frozen_string_literal: true
+
+# SimpleCov configuration file (auto-loaded before test suite)
+# This keeps test_helper.rb clean and follows best practices.
+# Coherent with the rest of the gem ecosystem (sessions, chats, moderate, …).
+
+SimpleCov.start do
+ # Use SimpleFormatter for terminal-only output (no HTML generation)
+ formatter SimpleCov::Formatter::SimpleFormatter
+
+ # Don't count the test suite itself toward coverage
+ add_filter "/test/"
+
+ # Generators run as a separate process against a real host app; their
+ # coverage comes from Rails::Generators::TestCase runs, which this in-process
+ # instrumentation does not observe.
+ add_filter "/lib/generators/"
+ add_filter "/lib/clickwrap/version.rb"
+
+ # Track Ruby files in both the library and the engine's app directory, since
+ # the models carry real behavior (projections, digests, disposition) rather
+ # than being thin ActiveRecord shells.
+ track_files "{lib,app}/**/*.rb"
+
+ # Enable branch coverage for more detailed metrics
+ enable_coverage :branch
+
+ # Minimum coverage thresholds to prevent coverage REGRESSION. These are a
+ # floor, not a target: raise them as coverage grows. A gem whose whole value
+ # is evidence that stays verifiable for years cannot afford untested
+ # canonicalization, lifecycle, or disposition paths.
+ #
+ # Set just under the actuals, which is the only setting that makes a floor do
+ # anything: at 80/60 against 91/72 actual, a change could delete a third of
+ # the branch coverage in this gem and still pass.
+ #
+ # The gap that remains is deliberate, and it is not slack for new untested
+ # code — it is the CI matrix. The database legs (sqlite / postgres / mysql)
+ # do not all reach the same lines: the update and delete protections are
+ # written for PostgreSQL, and the advisory-lock and concurrency paths only
+ # execute on some adapters. The floor has to hold on the LEANEST leg, so it
+ # sits below the richest one. Raise both numbers whenever every leg has
+ # cleared them for a while.
+ #
+ # Currently 92.10 line / 72.62 branch on the leanest leg (sqlite), so this is
+ # about a point of room in each. If a legitimate change spends it, add the
+ # tests rather than lowering these back.
+ #
+ # Measure it on a CLEAN coverage directory. SimpleCov merges resultsets within
+ # its merge timeout, so a full run following a `TEST=one_file.rb` run reports
+ # a number neither of them produced.
+ minimum_coverage line: 91, branch: 71
+
+ # Disambiguate parallel test runs
+ command_name "Job #{ENV["TEST_ENV_NUMBER"]}" if ENV["TEST_ENV_NUMBER"]
+end
+
+# Print coverage summary to terminal after tests complete
+SimpleCov.at_exit do
+ SimpleCov.result.format!
+ puts "\n#{"=" * 60}"
+ puts "COVERAGE SUMMARY"
+ puts "=" * 60
+ puts "Line Coverage: #{SimpleCov.result.covered_percent.round(2)}%"
+ branch_coverage = SimpleCov.result.coverage_statistics[:branch]&.percent&.round(2) || "N/A"
+ puts "Branch Coverage: #{branch_coverage}%"
+ puts "=" * 60
+end
diff --git a/AGENTS.md b/AGENTS.md
index d59df41..abacfaf 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,21 +1,29 @@
-# Agent instructions
+# AGENTS.md
-This repository is in a documentation-first product-definition phase.
+This file provides guidance to AI Agents (like OpenAI's Codex, Cursor Agent, Claude Code, etc) when working with code in this repository.
-Before changing anything, read in full:
+The public `README.md` is the short, house-style introduction to the gem. The full north-star product contract — the exhaustive description of every capability, claim, and boundary that `README.md` used to be — now lives in `docs/north-star-readme.md`; read it before designing or changing any public behavior, and keep it updated when public behavior changes. The `guides/` directory has the detailed integration guides. The product research corpus lives in `docs/`, which is deliberately git-ignored; read it if it is present locally, and never publish it.
-- `docs/PRD.md`
-- every file in `docs/research/`
-- every file in `docs/decisions/`
-- every file in `docs/strategy/`
+This gem is part of a coherent ecosystem (`railsfast`, `sessions`, `chats`, `moderate`, `organizations`, `pricing_plans`, `usage_credits`, `wallets`, `api_keys`). Match the ecosystem conventions exactly: a single `Clickwrap.configure do |config| … end` block, `has_*`/verb-style class macros, adapter objects + no-op-default hook procs, string class names constantized lazily, adaptive install migrations, Minitest with a `test/dummy` app, SimpleCov, and the README/docs voice.
-Until the PRD is explicitly approved:
+## What makes this gem different to work on
-- do not add a gemspec, `lib/`, Rails engine, generator, migration, CI workflow, or implementation test;
-- do not create a GitHub repository, push a branch, publish or reserve a gem name, or contact any third party;
-- preserve the distinction between law, cases, regulator guidance, technical standards, vendor claims, pinned source-code observations, and product-design inferences;
-- add an exact, direct URL for every new external factual or legal claim;
-- pin source-code citations to immutable commits rather than moving branches;
-- never describe the project as guaranteeing enforceability, compliance, audit acceptance, identity, trusted time, a qualified electronic signature, or legal advice.
+Clickwrap's value is evidence that is still true and still verifiable years after it was written. That makes a few ordinary habits actively dangerous here:
-Engineering values inherited from the surrounding gem ecosystem are: idiomatic Ruby and Rails, a small public API, one annotated configuration block, server-owned security decisions, adaptive generators, host-owned UI escape hatches, Minitest, Appraisal matrices, SimpleCov, RuboCop, and minimal runtime dependencies. These are proposed constraints until the PRD is approved; their evidence is in `docs/research/01-ecosystem.md`.
+1. **Never overclaim in a public string.** No public name, comment, error message, receipt field, task output, generated file, or documentation line may say or imply: compliant, GDPR compliant, enforceable, legally binding, court-proof, tamper-proof, audit approved, trusted time, qualified electronic signature, verified identity, physical location (for IP geolocation), that a person read or understood something, or anything that reads as legal advice. The gem provides evidence mechanics; the host application and its counsel own the legal text, lawful basis, substantive validity, capacity, authority, and retention periods.
+2. **Distinguish source classes.** In documentation, keep law, court decisions, regulator guidance, technical standards, vendor claims, pinned source-code observations, and product-design inferences visibly separate. Add an exact, direct URL for every external factual or legal claim, and pin source-code citations to immutable commits rather than moving branches.
+3. **Released evidence formats are permanent.** Every released receipt schema, canonicalization profile, digest field, event action, and lifecycle meaning gets a golden fixture, and new versions must keep verifying old receipts. A format change means a new explicit schema and verifier, never a silent reinterpretation. Do not edit a released migration underneath an installed application; add an upgrade migration.
+4. **Required writes cannot be error-isolated.** Evidence and the protected database action commit together or not at all. Optional after-commit hooks, analytics, and notifications are isolated and can never undo a committed action or stand in for one.
+5. **The browser is not a policy author.** Policy key, revision, document versions, validity, subject binding, retention, and request-evidence fields are resolved server-side and rechecked at submit. Never add a hidden field, parameter, or header that lets a client choose any of them.
+6. **Default to collecting nothing.** IP address, browser user-agent, and every individual IP-geolocation field stay off until a policy names them with a plain-English purpose and a retention decision. Never add an option that enables a category of personal data as a side effect of enabling something else, and never add an opaque profile switch (`gdpr_compliant_mode`, `full_evidence`, `maximum_evidence`, `legal_proof`).
+7. **Names read aloud.** Complete verb-and-noun names, positive booleans, destructive methods that say exactly what they delete, `ip_address` not `ip`, `browser_user_agent` not `ua`, `ip_geolocation` not `location`, `http_request` not `context`, `recorded_at_by_server` not `signed_at`. If an example does not make sense read aloud by a developer who has never seen the gem, the name is wrong.
+
+## Working here
+
+- Run `bundle exec rake test` before claiming anything works, and `bundle exec rubocop` before committing.
+- Add tests in the same change. Fault injection, concurrency, replay, stale-token, disposition, and golden-receipt tests are load-bearing, not extras.
+- Prefer explaining a refusal in a full sentence over raising a terse error. The policy compiler's job is to tell a developer what is wrong and what to do about it.
+- Do not add a runtime dependency. Every integration is an optional adapter with a working no-op default.
+- Do not publish, push, or create anything outside this repository without the owner asking for it.
+
+> **Release-candidate record:** the independent audit findings are resolved in the implementation and tracked in `docs/reviews/2026-08-15-production-release-checklist.md`. The remaining remote-CI, downstream proof, unfamiliar-developer, legal/privacy, and publication gates are recorded there; do not collapse them into a generic “production ready” claim.
diff --git a/Appraisals b/Appraisals
new file mode 100644
index 0000000..d33e2a6
--- /dev/null
+++ b/Appraisals
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+# Test the minimum supported Rails version (matches the gemspec floor). The
+# adaptive install migration, the composite unique indexes the idempotency and
+# current-grant guarantees depend on, and `ActiveRecord::Encryption` for the
+# request-evidence annex must all work here.
+appraise "rails-7.1" do
+ gem "rails", "~> 7.1.0"
+ # The :markdown renderer autodetects a host Markdown library; kramdown is
+ # the pure-Ruby one the test lane exercises (same as the main Gemfile).
+ gem "kramdown", require: false
+ gem "markdown-rails", require: false
+end
+
+appraise "rails-7.2" do
+ gem "rails", "~> 7.2.0"
+ # The :markdown renderer autodetects a host Markdown library; kramdown is
+ # the pure-Ruby one the test lane exercises (same as the main Gemfile).
+ gem "kramdown", require: false
+ gem "markdown-rails", require: false
+end
+
+appraise "rails-8.0" do
+ gem "rails", "~> 8.0.0"
+ # The :markdown renderer autodetects a host Markdown library; kramdown is
+ # the pure-Ruby one the test lane exercises (same as the main Gemfile).
+ gem "kramdown", require: false
+ gem "markdown-rails", require: false
+end
+
+# Test the latest Rails version — this is the default/main Gemfile anyway.
+appraise "rails-8.1" do
+ gem "rails", "~> 8.1.0"
+ # The :markdown renderer autodetects a host Markdown library; kramdown is
+ # the pure-Ruby one the test lane exercises (same as the main Gemfile).
+ gem "kramdown", require: false
+ gem "markdown-rails", require: false
+end
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 072a0b4..47c79a5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,617 @@
# Changelog
+All notable changes to this project are documented here.
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## [Unreleased]
+
+### Changed — the signup clickwrap is one line
+
+- **`form.clickwrap` renders ONE checkbox carrying ONE sentence** whenever every
+ statement in a policy is an ordinary, required, default-worded `agree_to` or
+ `acknowledge`:
+
+ ☐ I agree to the Terms of Service and I acknowledge the Privacy Policy.
+
+ The documents are linked inside the sentence, and the label IS the sentence —
+ pressing the words toggles the control and assistive technology announces both
+ together. Gone from the rendering: the "Required" flag, the visible "(opens in
+ a new tab)" hint, the version label under each link, and the list of documents
+ under each statement. The `required` attribute stays as progressive
+ enhancement (THE SERVER DECIDES is unchanged), the new-tab truth is now an
+ `sr-only` span rendered only when the link really does open one, and versions
+ stay on receipts, where somebody reading the record can act on them.
+
+ The evidence is unchanged. One control, several statements: the manifest signs
+ which statement keys it covers and which key it is submitted under, and the
+ server fans that one answer out to all of them — each recorded with its own
+ kind, documents, assertion, and lifecycle, exactly as before. An unticked or
+ absent control refuses every one of them. Whether one answer may cover several
+ statements is re-checked at capture against the FROZEN POLICY REVISION, not
+ taken on the token's word.
+
+ Nothing that a person could reasonably want to answer differently is folded
+ in: optional consents, recorded yes/nos, statements with a withdrawal route,
+ and copy an application wrote itself all keep a control of their own below the
+ line. A policy with nothing composable — operator attestation rails, payout
+ authorizations — renders exactly as it did before. `combined: false` on
+ `form.clickwrap` / `form.clickwrap_fields` asks for the itemized shape, and it
+ reaches the presenter rather than the template, so the manifest signs the
+ shape that was offered. An itemized manifest is byte-identical to the ones
+ earlier versions wrote.
+
+ The words are translations: `clickwrap.sentence.agreement`,
+ `clickwrap.sentence.acknowledgment`, and three connectives, with `%{documents}`
+ marking where the links go. A locale that has not translated them renders the
+ itemized shape rather than half an English sentence.
+
+ Custom surfaces iterate `presentation.itemized_statements` rather than
+ `statements`, and render `clickwrap_combined_sentence(presentation.combined)`
+ when there is one. The presentation linter keeps its preselected-control and
+ action-ordering rules and gains
+ `combined_statement_rendered_as_its_own_control`, which is where an ejected
+ view lands after this change.
+
+- **Receipts record the sentence that was read.** `presentation` gained
+ `combined_sentence` and `combined_statements`, shown on the HTML receipt and
+ the engine's receipt screen. The acts say what was recorded; this says what
+ was on the screen. Both are absent — not null — on an itemized presentation,
+ so every receipt written before this change verifies byte for byte exactly as
+ it did.
+
+### Added — `link:`, the page a person actually reads a document on
+
+- **`Clickwrap.document :terms, from: ..., link: "/legal/terms"`** presents and
+ signs the host's own formatted page instead of the engine's rendering of the
+ published bytes. It is the path rendered AND the path signed, so evidence
+ never cites a different target from the link somebody pressed, and the trade
+ is written where a reviewer sees it: a host page shows whatever is current, so
+ the signed path is a stable address rather than an immutable snapshot. The
+ bytes stay frozen, digested, and recorded either way.
+
+ Precedence: a resolver passed at the call site, then `link:`, then the render
+ context's default resolver (the mounted engine route, with this request's
+ Hotwire Native treatment attached — which therefore applies to `link:` paths
+ unchanged), then the engine's own routes. The refusal to sign a link an
+ unmounted engine cannot resolve is about the ENGINE route, so a policy whose
+ every document names a host page now presents with no mount at all, and the
+ refusal keeps firing for every document that does not.
+
+ A `link:` is checked at boot for a scheme a browser can navigate:
+ `javascript:`, `data:`, a bare word, and protocol-relative `//host` are
+ refused with the sentence that fixes them.
+
+- The presenter's framework wiring moved from `document_version_path_with:` to
+ `default_document_version_path_with:`, leaving the former as what it always
+ read like — the host's own resolver, which still wins outright.
+ `clickwrap_document_version_path_for_presentation` gained a `declared_link:`
+ keyword.
+
+### Added — the quickstart runs in CI
+
+- **The README quickstart is now executed end to end**, in a subprocess, against
+ a throwaway application built only from what the installer emitted: install →
+ migrate → declare → `has_clickwraps` → publish → render the form → submit the
+ token that render produced → the evidence row exists → the receipt verifies.
+ It is the one document every user reads and was the only one nothing checked.
+
+ It found real defects on its first run, all of them consequences of emitting
+ fewer tables: capture marked a presentation accepted without asking whether
+ the policy retains presentations, and receipts, verification, and the
+ retention planner read optional annex tables unconditionally. Every
+ association whose table is optional now answers "there is nothing here" when
+ the table was never created — which is not a fallback but the exact truth,
+ since without the table no row was ever written.
+
+### Changed — the emitted footprint
+
+- **The generated initializer states decisions, not defaults.** It carried about
+ thirty live lines that assigned the gem's own default back to itself, so a
+ reader could not tell which lines somebody had chosen and which were noise —
+ and every one of them was a line to maintain in two places forever. Live lines
+ are now only what the installer actually decided or detected; every other
+ setting appears commented, with its default value, under prose that says what
+ it does. A default install drops from about thirty live settings to three,
+ plus the eleven request-evidence lines.
+
+ Those eleven stay live even when every answer is `false`, deliberately: each
+ is an answer to a question the installer asked, and "we decided not to collect
+ this" is a decision worth reading rather than inferring from a file that does
+ not mention it. The file says so where the rule is stated.
+- **`clickwrap:install` emits only the tables an installation can put a row
+ in.** Seven of the seventeen tables — persisted presentations, the
+ request-evidence annex, chain heads, integrity attestations, external
+ actions, disposition plans, legal holds — are each gated on a configuration
+ that is off by default, so a default install could never write to any of
+ them. A schema that contains them anyway claims capabilities and data
+ categories the application does not have, which is exactly the impression
+ this gem exists not to give. Each now arrives with its own flag
+ (`--with-persisted-presentations`, `--with-request-evidence`,
+ `--with-integrity`, `--with-retention-ops`, `--with-external-actions`),
+ following the `clickwrap:hardening` precedent, and re-running the generator
+ later with a flag adds that migration then.
+
+ Enabling a request-evidence field implies `--with-request-evidence`: an
+ installation that records IP addresses into a table it never created is not
+ a schema choice, and the operator already answered the question that matters.
+
+ The one failure mode this trade creates — turning a capability on and
+ forgetting its migration — is caught three ways, each naming the exact
+ command: at boot, by `bin/rails clickwrap:doctor`, and at the entry points
+ no configuration announces (`authorize_external_action!`, legal holds,
+ disposition planning). The boot check deliberately stays quiet while
+ migrations are pending, because refusing to boot would make the fix
+ unrunnable.
+
+### Fixed — the two-audit adversarial review
+
+Two independent audits read the gem as an unfamiliar developer would: one
+followed the installer and the README literally, the other read the code
+against its own claims. Everything below is a defect they evidenced.
+
+- **The installer and the README never taught the auth door.** A Devise app
+ that followed the post-install message verbatim created accounts with no
+ evidence at all, silently — the gem is never called on that path, so nothing
+ could warn about it. The post-install message and the README quickstart now
+ both carry the door step, with the exact line, the host's own file path and
+ class name, and the `devise_for ..., controllers:` route that makes the
+ subclass reachable. "The installer detects your authentication stack and
+ generates an explicit adapter" now says what it does: it detects, and prints
+ the line for you to add.
+- **`renew!`, `correct_declaration!`, and `change_consent_scope!` had no tests,
+ no documentation, and no callers.** They are the conceptual spine of the six
+ verbs, so they are now tested behaviorally — the event is appended, the
+ earlier event still says exactly what it said and still verifies, and the new
+ receipt verifies on its own — and the README shows each one with the sentence
+ that says what it means. Writing those tests surfaced the reason nobody had
+ used them: all three capture through a real presentation, so each needs a
+ `submission:` exactly as the original statement did. That is correct — a
+ correction, a renewal and a rescope are new statements by the same person,
+ not administrative flags — but it was nowhere in the docs, and the README now
+ says it.
+- **`config.actor_class_name` now decides something.** The installer spends
+ sixty lines, a `--actor-class` option, and a seven-line warning on this
+ setting because "a wrong guess attributes evidence to the wrong kind of
+ record for years" — which was not true, because nothing checked. The setting
+ fed one error string, and `Configuration#actor_class` had no production
+ caller at all. Capture now asserts the recorded actor is an instance of it,
+ with an error that names the setting and points an organization at
+ `acting_for:` where it belongs. System actors, anonymous actors, and literal
+ references to actors in other systems are their own kinds, say so in the
+ receipt, and are not checked against the class.
+- **The receipt list authorized after paging.** Taking a page of rows and
+ filtering them afterwards rendered "you have no receipts" whenever the
+ viewer's newest page happened to be rows the host would not show them, while
+ readable ones sat past the limit. Authorization now happens first, in bounded
+ batches, and the actor is eager-loaded — a full page is three queries instead
+ of fifty-two.
+- **A presentation resolved its documents one statement at a time**, including
+ the same document twice when two statements named it, even though documents
+ are immutable and published and the answer cannot differ. Two queries now
+ resolve every document a policy references.
+- **`Clickwrap.reset!` left the presentation-manifest verifier memoized**, so a
+ verifier built under the previous configuration survived the reset. The test
+ suite had been resetting it by hand, which is why nothing caught it.
+- **`Clickwrap.verify(event_id, …)` silently dropped its strictness keywords.**
+ The event-id branch accepted `require_current_revision:` and then never
+ received it, so a host asking "is this old evidence still good?" about one
+ recorded act got an unqualified yes — worse than an error, because it looked
+ like an answer. Both branches now answer the whole question: the event id
+ compares the act's recorded policy revision against the wording compiled
+ today, and re-derives the subject fingerprint from the live record when
+ `subject:` is passed. A policy that is no longer declared answers
+ `:unknown_policy` rather than passing, because "we can no longer check this"
+ must never be spelled the same way as "this is fine". `Clickwrap.verify(event_id,
+ subject:, require_current_revision: true)` is therefore the complete public
+ form of that question, and nothing needs to reach into
+ `Clickwrap::PolicyRevision` or `Clickwrap::SubjectFingerprint` to ask it.
+ Verifying an event id also no longer writes: the revision comparison is by
+ digest, so a read-only question stops creating a revision row as a side
+ effect.
+- **An unmounted engine used to sign 404 document links into evidence.** The
+ document link goes into the presentation manifest, into the digest, and into
+ the record of what was offered; built from an unmounted engine's routes it
+ resolves to nothing, and nothing downstream can detect that afterwards
+ because the digest is over the wrong link. Presenting now refuses at build
+ time with the mount line in the sentence, `bin/rails clickwrap:doctor`
+ reports the missing mount as a problem whenever any policy is compiled (it
+ used to speak only about `requires_clickwrap` gates), and a non-interactive
+ install mounts the engine and says so instead of silently taking the `[y/N]`
+ default — `--skip-routes` is the one flag that declines.
+
+### Added — the dream-API pass (driven by installing the gem into a second real application)
+
+Two real installations wrote about a hundred lines of nearly identical glue
+around this gem. That is the strongest possible evidence that the code belongs
+in the gem, so each ceremony became behavior, working backwards from the API we
+wished we had written.
+
+- **`version:` is optional when the source names its own.** A document declared
+ `from:` a file — or with inline `content:` — whose leading YAML front matter
+ carries `clickwrap_version:` or `last_updated:` resolves its version label
+ from there, so the file that *is* the legal text also names its version and
+ there is no second copy of the label anywhere to drift. `clickwrap_version:`
+ outranks `last_updated:` on purpose: a same-day correction that still changes
+ bytes needs a fresh label while the date readers see stays put. Both host
+ applications had written the same front-matter reader module to do this
+ themselves; that module stops existing. The old failure mode was a label
+ living in `config/clickwrap.rb` and words living in a file, changed in
+ separate edits — which either publishes as "same label, different bytes"
+ (refused at publish, correctly, but a step later than it needed to be) or
+ quietly leaves the label describing text nobody is serving any more. A source
+ with no front-matter key and no `version:` is now a boot failure with the fix
+ in the sentence, and a `resolver:` source (whose bytes are only read at
+ publish time) says exactly why it cannot name its own. Clickwrap never invents a
+ label, because a policy that requires a current version cannot be satisfied
+ by a guess.
+- **`save`/`save!` pairing for doors and captures.** `register_with_clickwrap`
+ (non-bang) absorbs a refused registration — a stale presentation, an unticked
+ control, a validation the account failed — into the exact human sentences the
+ Devise adapter paints, through one shared `Clickwrap::Registration.absorb_refusal`:
+ inline via `clickwrap_errors` beside the control it belongs to, once on the
+ record's `:base`, then `false`, ready for `render :new, status: :unprocessable_entity`.
+ `capture_clickwrap` and `capture_clickwrap_and` do the same for
+ `Clickwrap::CaptureRefused`, leaving the refusal on `clickwrap_refusal` with
+ its `user_facing_message`. The old failure mode was every hand-rolled door
+ writing its own rescue, each one slightly different, and one of them
+ eventually rescuing too much. Infrastructure failures still escape every
+ form — `Clickwrap::EventWriteFailed` is not a validation error to dress up —
+ and lifecycle conflicts (`ReplayRejected`, `OneTimeAuthorizationConflict`)
+ still raise, because "this was already done" needs a domain answer that no
+ generic rescue can supply honestly.
+- **`config.document_renderer = :markdown_rails` renders through the
+ application's own renderer.** Content-file Rails apps already serve their
+ public legal pages through a registered markdown-rails handler; when the same
+ files are Clickwrap documents, what people accept and what the page serves
+ must be the same bytes — same renderer, same options, no second sanitization
+ pass changing entities behind the digest. Wiring that by hand meant a lambda,
+ an engine name, gem versions, and a parity test copied between applications;
+ this setting says it once, records honest provenance (the renderer class the
+ application registered, plus the markdown-rails and Markdown-engine gem
+ versions), and resolves the handler lazily at first render so initializer
+ order cannot capture the stock default renderer instead of the application's.
+ Its bound is stated rather than hidden: the renderer is called without a view
+ context, exactly how a frozen snapshot must render, so Markdown that calls
+ Rails view helpers fails loudly at publish and needs the explicit lambda form.
+- **Publishing rides `db:prepare`.** The deploy step everyone forgets no longer
+ exists: an enhanced `db:prepare` publishes declared documents, so by the time
+ the server takes traffic every declared version has an immutable snapshot.
+ The old failure mode was the worst kind of quiet — presentations refuse
+ unpublished documents (the safe failure), so a forgotten `clickwrap:publish`
+ became "nobody can sign up" some hours after a deploy that looked green. It
+ is idempotent, silent when no documents are declared, and a sentence rather
+ than a crash when the tables are not migrated yet; a real refusal fails the
+ deploy out loud, which beats signups failing quietly later. Opt out with
+ `config.publish_documents_after_database_preparation = false`.
+- **`config.hotwire_native_document_links` answers the native question once.**
+ One declarative seam does both halves coherently — the signed href
+ (absolutized against a validated `https` canonical host, which may be a
+ callable) and the navigation attributes (`target="_blank"`,
+ `rel="noopener"`, `data-turbo="false"` for `:external_browser`; a plain link
+ for `:same_screen`). It exists for one specific failure: on a native
+ authentication sheet, a same-host document link is routed by the app itself,
+ which pops the sheet and takes the half-filled signup form with it. Getting
+ the href right in one place and the attributes right in another is exactly
+ the kind of split that drifts. When set it answers native renders entirely;
+ `config.document_link_html_options_with` goes on answering everything else,
+ so an app needing different answers per screen keeps the per-request lambda.
+- **The default `describe_authentication_with` no longer over-claims.** It now
+ reports `{ method: :authenticated_session }` only when the controller's
+ configured current-actor method actually returns someone, and `{}` otherwise.
+ A signup form is not an authenticated session, and describing every request
+ as authenticated merely because it passed through `ApplicationController` put
+ a claim in the receipt that nobody had checked. It is deliberately gentler
+ than the capture path's actor resolution: describing authentication is
+ context, not identity, so a controller with no such method is `{}`, never an
+ error.
+
+### Added — installer improvements (driven by installing the gem into a template application)
+
+- **Non-interactive runs skip the questions instead of jumbling them.** When
+ `rails generate clickwrap:install` runs without a terminal (a piped or
+ scripted run, CI, an AI agent), it now announces that it detected a
+ non-interactive run and takes the same collect-nothing defaults as
+ `--skip-questions`, rather than streaming interactive prompts into a pipe
+ that would have taken every `[y/N]` default anyway.
+- **Existing legal pages become the document source.** When the host already
+ keeps both legal documents at a recognized convention — Sitepress-style
+ `app/content/pages/legal/terms.html.md` + `privacy.html.md`, or a previous
+ install's `app/content/legal/*.md` — the generated `config/clickwrap.rb`
+ points its `from:` lines at those exact files and no placeholders are
+ written. What people accept and what the public legal routes render must be
+ the same bytes, and a second copy is how they silently stop being the same
+ document. Placeholders are still written when no convention matches, and a
+ convention only counts when both of its documents exist.
+
+### Added — the hardening pass (driven by an independent adversarial audit of the first production integration)
+
+- **Policy-declared tenant semantics: `tenant_is :not_applicable | :optional | :required`.**
+ Presentation, capture, verification, and import all resolve the tenant
+ through the policy's declaration, and the resolved tenant is signed into
+ the presentation manifest — so a personal policy can never inherit an
+ ambient organization from the session, and an organization member can no
+ longer be dead-ended by a `presentation_tenant_mismatch` between a
+ tenant-less render and a tenant-injecting submit. Withdrawal matches each
+ grant under its own policy's semantics, so consent granted personally stays
+ withdrawable after the person joins an organization; exemptions record the
+ canonical tenant reference. Declare `tenant_is :not_applicable` explicitly
+ on personal policies — it is the difference between "this evidence never
+ changes identity" and "whatever organization happened to be current."
+- **Import chronology and revision honesty.** An imported historical grant
+ can never replace a newer live state (effective-time-first comparison, with
+ server recording order as tie-breaker only); imported evidence carries an
+ explicit legacy-import revision identity that can never satisfy
+ `require_current_revision: true`; and **`counts_as_current: false`**
+ quarantines an import — it satisfies no predicate, survives projection
+ REBUILDS quarantined, and exists for exactly the case where counsel has not
+ yet blessed a legacy source (a bundled checkbox, an unverified column).
+ Import idempotency covers statement mappings and provenance.
+- **Serialized evidence writes.** Every state-mutating path (capture, import,
+ exemption, lifecycle transitions, provider outcomes, projection rebuild)
+ takes a per-actor identity lock first and the chain head second, in one
+ documented order, so concurrent writers block instead of deadlocking and
+ lost-update races on statement states are gone. The concurrency lane runs
+ on PostgreSQL and MySQL — SQLite cannot express row locks.
+- **`recorded_after?` is off ULIDs.** Ordering questions are answered by a
+ durable database sequence under a unique chain position; ULID comparison —
+ process-local monotonicity — is no longer presented as an ordering
+ guarantee anywhere. The sequence is the installation's PRIVATE order: it is
+ deliberately absent from canonical bodies and receipts, because publishing
+ a global counter would hand every receipt holder an enumerable census of
+ installation activity.
+- **Atomic protected outcomes.** `record_protected_outcome_with:` on a
+ one-time authorization records the exact result of the protected action —
+ built via `Clickwrap.protected_outcome`, digest-covered, committed in the
+ same transaction, refused on recorder-version drift — so "this evidence
+ authorized this exact operation" names the operation's amount, record, and
+ state instead of implying them.
+- **Immutable document navigation.** The signed manifest binds each
+ statement's immutable document path; choice/radio statements render their
+ document links (previously only checkboxes did); and
+ `config.document_link_html_options_with` lets hosts choose HOW links open
+ (evaluated in the rendering view, so `hotwire_native_app?` works) while the
+ href itself is refused under any capitalization. The "opens in a new tab"
+ hint renders only when the link actually does.
+- **Engine route authorization.** Receipts and withdrawal routes require a
+ present actor before the host callback runs, and the generated
+ authorization example now guards `nil == nil` explicitly. Remediation
+ tokens CARRY their signed tenant into presentation and capture instead of
+ comparing it against an ambient value the engine's routes cannot have.
+- **Evidence-contract model links.** `has_clickwrap_evidence` takes the full
+ contract (`policy:`, `statement:`, `actor:`, `subject:`, optional
+ `tenant:`/`represented_party:`/`required_for_new_records:`), validates a
+ linked event against it, refuses link replacement, and supports model-first
+ deployments (inert until the column exists; strict from then on).
+- **Submission hardening.** Forged envelope fields raise instead of being
+ dropped; answers are bounded at `Submission::MAX_ANSWER_LENGTH` characters
+ and refused — never truncated — beyond it (an answer is a checkbox state or
+ a declared choice name, not free text).
+- Atomic represented-party creation (`create_represented_party_with_clickwrap`
+ and `including_when_this_action_creates_the_organization:`), atomic local
+ projections for external actions, request-aware geolocation provenance, and
+ pending-receipt answer readers (`answer_for`, `answered?`, `granted?`,
+ `declined?`) that read the validated event being committed instead of
+ re-parsing browser params.
+
+Changes driven by the first production host application:
+
+### Changed
+
+- **Imported legacy evidence now satisfies the everyday predicates.**
+ `Clickwrap.import_legacy!` projects into current state exactly as a capture
+ does, so `agreed_to?`, `acknowledged?`, and `current_for?` keep answering
+ what the source system answered — a migration no longer implies mass forced
+ re-acceptance. Provenance is unchanged (`imported_legacy` event type,
+ `imported_provider` attribution, unknowns named in the receipt), and
+ `require_current_version: true` still re-prompts when documents move on.
+ 0.1.0 was never published, so no installed application observes a behavior
+ change.
+- The Devise adapter refuses a submission with a missing/stale presentation or
+ a declined required statement on the re-rendered form, with the gem's
+ localized user-facing sentences (inline beside the control and on `:base`) —
+ never a raw exception or a developer-facing message.
+- The framework-integration modules (`FormBuilderExtensions`,
+ `ControllerHelpers`, `Registration`, `DocumentRenderers::Markdown`) are
+ required at boot instead of autoloaded, so hosts whose other gems load
+ Action View/Action Controller first no longer fail with an uninitialized
+ constant.
+- The install migration recognizes the PostGIS adapter as PostgreSQL (jsonb)
+ and Trilogy as MySQL; the generated initializer selects the `:safe_text`
+ renderer explicitly instead of writing `nil` (which disabled rendering).
+
+### Added
+
+- **Public forms that find or create their record:**
+ `register!(..., actor_may_already_exist: true)`. The lead-capture /
+ newsletter shape — an anonymous visitor submits a public form and the host
+ resolves the row by typed email — is one `register_with_clickwrap` call for
+ both cases now. When the submission created the row, attribution stays
+ `account_registration`; when it matched an existing row, the receipt
+ records the new `public_form` attribution instead, because no account was
+ created by the act. Without the explicit option, a persisted prospective
+ actor is still refused (that is usually a bug — the host meant `capture!`
+ with `actor:`), and the refusal teaches the option. (Forced by migrating a
+ production lead-magnet funnel whose leads upsert by email.)
+- **`Clickwrap::CaptureRefused` with `#user_facing_message`.** Every refusal a
+ person can cause from a form — a stale or missing presentation, an
+ unparseable submission, a declined required statement — now shares one
+ exception superclass carrying a localized sentence fit to show them, so a
+ host controller handles the whole family in one rescue:
+ `rescue Clickwrap::CaptureRefused => refusal; redirect_to ..., alert:
+ refusal.user_facing_message`. Infrastructure failures stay outside the
+ family and stay loud. (Extracted from a real host that had grown five
+ hand-written rescue sites for the same distinctions.)
+- **`has_clickwrap_evidence` + `bin/rails generate clickwrap:link TABLE`.**
+ One macro and one generator for the row-level link between a domain record
+ and the capture that authorized it: the generator writes the
+ `clickwrap_event_id` column migration (ULID string, indexed, nullable, no
+ foreign key — each deliberate, and the migration says why), and the macro
+ gives the model `clickwrap_event` and `clickwrap_receipt`, so
+ `withdrawal.clickwrap_receipt.verify` is one line years later.
+- **`Result#recorded_after?(other)`** on verification results — enforce
+ evidence ordering ("the declaration must postdate the preparation") without
+ hosts comparing event ids by hand; accepts another result or a bare event
+ id and is false whenever either side is missing.
+- The installer's post-install checklist now includes the test-suite setup:
+ include `Clickwrap::TestHelpers`, publish once per parallel worker and once
+ per process, and read submissions off rendered pages with
+ `clickwrap_params_from` — presentation tokens are signed and session-bound,
+ so tests cannot fabricate them by hand (that is the point).
+- **[Integrating guide](guides/integrating.md)** — the battle-tested playbook
+ from migrating a production application onto the gem end to end: install
+ order, pointing documents at real legal content, test setup, Devise
+ dual-write bridges, custom surfaces, money-path protection, legacy import,
+ request-evidence enablement, and the dual-write → dual-belt → retire
+ rollout doctrine.
+- **View helpers for custom surfaces** (`Clickwrap::ViewHelpers`, available in
+ every view): `clickwrap_presentation_token_field`,
+ `clickwrap_statement_check_box`, `clickwrap_statement_radio_button`, and
+ `clickwrap_submit_button` own the three contracts a hand-written form gets
+ wrong silently — the envelope name, the statement control names/ids, and a
+ call to action worded by the signed manifest itself. The host owns every
+ class and wrapper around them. (Extracted from the first production host's money-path
+ migration, where each custom form repeated all three by hand.)
+- **`Clickwrap.verify(..., require_current_revision: true)`** — opt-in
+ revision currency: evidence recorded under a superseded policy revision
+ fails with `:stale_policy_revision` (the verify-time counterpart of the
+ capture-time symbol), so "legal reworded the statement → re-ask" is one
+ keyword instead of a hand-rolled revision comparison at the host's service
+ boundary.
+- **Predicates on verification results**, one per stable error symbol and
+ generated from the vocabulary so they can never drift:
+ `result.no_evidence?`, `result.subject_fingerprint_mismatch?`,
+ `result.stale_policy_revision?`, …
+- `clickwrap_params_from(path, answers: {})` in TestHelpers — the one-line
+ integration-test pattern: GET the page, read the signed token and controls
+ back off it, return the POST params.
+- The unknown-document boot error now mentions `document: nil` for statements
+ about operational facts with no published document.
+- `config.document_renderer = :markdown` — real HTML through whichever
+ Markdown library the host already bundles (commonmarker, redcarpet, or
+ kramdown; no new dependency), with leading YAML front matter stripped from
+ the rendered representation only, the engine name and version recorded in
+ the receipt, and the same safe-list sanitizer as the reference renderer.
+- Spanish locale (`config/locales/es.yml`).
+- `clickwrap_submission_params_from(response)` test helper: host integration
+ tests read the signed presentation token and its controls back off the
+ rendered page, the way a browser does.
+
+## [0.1.0] - 2026-08-15
+
+First implemented release. `clickwrap` turns terms acceptance, privacy notice
+acknowledgment, consent, factual declarations, operator attestations, and
+one-time authorizations into one Rails primitive: immutable versioned
+documents, server-owned policies, signed presentation manifests, append-oriented
+evidence events with fixed named disposition transitions, and canonical receipts that can be checked without the
+application that wrote them. Required evidence and the protected database
+action commit in the same transaction, so an account, payout, or handoff cannot
+succeed without the evidence that authorized it. Request evidence — IP address,
+browser user-agent, IP geolocation — stays off until a policy names the field,
+its purpose, and its retention. The gem provides evidence mechanics only: your
+application and its counsel still own the legal text, lawful basis, substantive
+validity, capacity, authority, and retention periods.
+
+### Added
+
+- **Immutable versioned documents.** `Clickwrap.document :terms, version:, from:`
+ points at the files your application already owns; `bin/rails clickwrap:publish`
+ freezes each version into a snapshot with a versioned SHA-256 digest of the
+ exact bytes, and `clickwrap:publish:plan` previews what a boot would publish.
+ A published version is never rewritten — editing the source file is a new
+ version, and receipts keep resolving the bytes they were captured against.
+- **Server-owned compiled policies with six honest kinds.** `Clickwrap.policy`
+ and its verbal DSL — `agree_to` (agreement), `acknowledge` (acknowledgment),
+ `consent_to` (consent), `declare` (declaration), `attest` (attestation),
+ `authorize` (authorization) — give each act the lifecycle it actually needs
+ instead of calling every checkbox "consent." The policy compiler runs at boot
+ and refuses incoherent combinations in a full sentence: an indefinite one-time
+ authorization, consent without a withdrawal path, an expiring declaration that
+ would have to pretend the original statement was false. The taxonomy is
+ product design, not statutory vocabulary; the host picks the kind.
+- **Signed presentation manifests.** Every rendered policy carries a signed,
+ short-lived manifest of exactly what the server generated and offered: policy key and
+ revision, document versions and digests, assertion and link text, choices,
+ submit-button text, and locale. Submission is validated against that manifest
+ and rechecked server-side, so render-to-submit substitution — a different
+ version, a different call to action, a checkbox the server never required —
+ is rejected rather than recorded. The browser may answer a policy; it can
+ never choose the policy, the version, the validity window, the subject, the
+ retention class, or a request-evidence field.
+- **`capture!`, `capture_and!`, and `register!` with same-transaction atomicity.**
+ `capture_and!` yields a read-only `Clickwrap::PendingReceipt` whose stable
+ `event_id` the domain row can store, and commits the evidence and the
+ protected action together or not at all; if the transaction rolls back the
+ pending object becomes invalid instead of masquerading as committed evidence.
+ `capture!` records evidence on its own, and `register!` binds a prospective
+ actor to the presentation that preceded the account — the Rails-authentication
+ and Devise adapters are thin conveniences over it. Optional
+ `after_event_is_committed` hooks are error-isolated and can never undo a
+ committed action or stand in for one.
+- **Lifecycles that stay truthful over time.** Consent can actually be
+ withdrawn, renewed, and scope-changed; declarations expire, get corrected, and
+ get superseded without rewriting what was originally stated; one-time
+ authorizations are locked and consumed inside the same transaction as the
+ action they authorize, so a stale token, a changed subject, a wrong ordering,
+ or a concurrent replay cannot reuse one. Every ordinary lifecycle transition
+ appends an event with its own predecessor link; reviewed retention uses the
+ separately named, fixed disposition transition rather than masquerading as an
+ ordinary append.
+- **Canonical receipts and a standalone verifier.** Receipts use versioned
+ schemas serialized with the [JSON Canonicalization Scheme (RFC 8785)](https://www.rfc-editor.org/rfc/rfc8785)
+ plus a published Clickwrap profile for UTC timestamps, decimals, identifiers,
+ binary digests, absent values, and extension names — never Ruby object
+ serialization, YAML, or database column order. `bin/rails clickwrap:verify`
+ and `clickwrap:export` produce and check bundles without the host
+ application's source code, and golden fixtures pin every released format so
+ new versions keep verifying old receipts. An unknown schema version fails
+ honestly instead of being reinterpreted. The baseline tier verifies schema,
+ canonical bytes, digests, links, and bundled content consistency, and says so
+ precisely; it claims nothing about origin or time that it cannot show.
+- **Optional request evidence, off by default and encrypted.** IP address,
+ browser user-agent, and each individual IP-geolocation field are collected
+ only when a policy names them with a plain-English purpose and a retention
+ decision. They live in a separate `ActiveRecord::Encryption` annex, are read
+ through their own authorization callback, and are separately disposable
+ without touching the core event. There is no `gdpr_compliant_mode`,
+ `full_evidence`, or `legal_proof` switch that turns on a category of personal
+ data as a side effect of something else — the installer's recipes write every
+ individual setting into the initializer and then disappear.
+- **Retention classes, legal holds, and dry-run disposition.**
+ `Clickwrap.retention` expresses per-field retention (including event-based and
+ "later of" rules) as executable, auditable policy. `clickwrap:retention:plan`
+ produces an immutable, scoped, expiring plan that `clickwrap:retention:apply`
+ rechecks before touching anything, so a newly placed hold or a changed policy
+ stops disposition instead of deleting more than the operator reviewed.
+ `place_on_legal_hold!` / `release_legal_hold!` require a reason, an owner, and
+ a review date; placing and releasing a hold append corresponding evidence
+ events while the hold row remains an explicit current-state record. Destructive methods
+ name exactly what they remove (`delete_recorded_ip_address!`,
+ `delete_recorded_browser_user_agent!`, `delete_recorded_ip_geolocation!`) and
+ append a disposition event rather than rewriting history. Clickwrap does not
+ decide retention periods; it makes reviewed ones executable.
+- **Generators for every step.** `clickwrap:install` detects integer versus UUID
+ keys, the database adapter, and Rails authentication versus Devise; it stops
+ and explains itself when the actor or tenant mapping is ambiguous, asks
+ separately about every request-evidence field, and prints a post-install
+ checklist. `clickwrap:policy`, `clickwrap:document`, `clickwrap:views`,
+ `clickwrap:hardening --database`, and `clickwrap:upgrade` cover the rest.
+ Upgrade generators always create new migrations; a released migration is never
+ silently edited underneath an installed application.
+- **Importers that do not invent history.** `clickwrap:import:fine_print:plan` /
+ `clickwrap:import:fine_print` turn FinePrint contract versions and signatures
+ into explicit `imported_legacy` events, and `Clickwrap.import_legacy!` does the
+ same for an `accepted_terms_at` column. Fields the legacy source never
+ recorded — presentation manifest, IP address, call to action, protected
+ action — stay `unknown` or `not_collected`. Clickwrap never synthesizes
+ evidence it does not have.
+- **A form-builder helper and ejectable reference views.**
+ `form.clickwrap :signup, submit: "Create account"` renders the initially
+ unselected controls and the bound submit button as one presentation, so the
+ call to action in the signed manifest is the one the user can actually press.
+ `rails generate clickwrap:views` ejects the reference views — including the
+ standalone remediation screen — for hosts that want their own markup.
+
## [0.0.0]
- Name-reservation release. No implementation: no engine, no models, no
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..5fb0b40
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,29 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+The public `README.md` is the short, house-style introduction to the gem. The full north-star product contract — the exhaustive description of every capability, claim, and boundary that `README.md` used to be — now lives in `docs/north-star-readme.md`; read it before designing or changing any public behavior, and keep it updated when public behavior changes. The `guides/` directory has the detailed integration guides. The product research corpus lives in `docs/`, which is deliberately git-ignored; read it if it is present locally, and never publish it.
+
+This gem is part of a coherent ecosystem (`railsfast`, `sessions`, `chats`, `moderate`, `organizations`, `pricing_plans`, `usage_credits`, `wallets`, `api_keys`). Match the ecosystem conventions exactly: a single `Clickwrap.configure do |config| … end` block, `has_*`/verb-style class macros, adapter objects + no-op-default hook procs, string class names constantized lazily, adaptive install migrations, Minitest with a `test/dummy` app, SimpleCov, and the README/docs voice.
+
+## What makes this gem different to work on
+
+Clickwrap's value is evidence that is still true and still verifiable years after it was written. That makes a few ordinary habits actively dangerous here:
+
+1. **Never overclaim in a public string.** No public name, comment, error message, receipt field, task output, generated file, or documentation line may say or imply: compliant, GDPR compliant, enforceable, legally binding, court-proof, tamper-proof, audit approved, trusted time, qualified electronic signature, verified identity, physical location (for IP geolocation), that a person read or understood something, or anything that reads as legal advice. The gem provides evidence mechanics; the host application and its counsel own the legal text, lawful basis, substantive validity, capacity, authority, and retention periods.
+2. **Distinguish source classes.** In documentation, keep law, court decisions, regulator guidance, technical standards, vendor claims, pinned source-code observations, and product-design inferences visibly separate. Add an exact, direct URL for every external factual or legal claim, and pin source-code citations to immutable commits rather than moving branches.
+3. **Released evidence formats are permanent.** Every released receipt schema, canonicalization profile, digest field, event action, and lifecycle meaning gets a golden fixture, and new versions must keep verifying old receipts. A format change means a new explicit schema and verifier, never a silent reinterpretation. Do not edit a released migration underneath an installed application; add an upgrade migration.
+4. **Required writes cannot be error-isolated.** Evidence and the protected database action commit together or not at all. Optional after-commit hooks, analytics, and notifications are isolated and can never undo a committed action or stand in for one.
+5. **The browser is not a policy author.** Policy key, revision, document versions, validity, subject binding, retention, and request-evidence fields are resolved server-side and rechecked at submit. Never add a hidden field, parameter, or header that lets a client choose any of them.
+6. **Default to collecting nothing.** IP address, browser user-agent, and every individual IP-geolocation field stay off until a policy names them with a plain-English purpose and a retention decision. Never add an option that enables a category of personal data as a side effect of enabling something else, and never add an opaque profile switch (`gdpr_compliant_mode`, `full_evidence`, `maximum_evidence`, `legal_proof`).
+7. **Names read aloud.** Complete verb-and-noun names, positive booleans, destructive methods that say exactly what they delete, `ip_address` not `ip`, `browser_user_agent` not `ua`, `ip_geolocation` not `location`, `http_request` not `context`, `recorded_at_by_server` not `signed_at`. If an example does not make sense read aloud by a developer who has never seen the gem, the name is wrong.
+
+## Working here
+
+- Run `bundle exec rake test` before claiming anything works, and `bundle exec rubocop` before committing.
+- Add tests in the same change. Fault injection, concurrency, replay, stale-token, disposition, and golden-receipt tests are load-bearing, not extras.
+- Prefer explaining a refusal in a full sentence over raising a terse error. The policy compiler's job is to tell a developer what is wrong and what to do about it.
+- Do not add a runtime dependency. Every integration is an optional adapter with a working no-op default.
+- Do not publish, push, or create anything outside this repository without the owner asking for it.
+
+> **Release-candidate record:** the independent audit findings are resolved in the implementation and tracked in `docs/reviews/2026-08-15-production-release-checklist.md`. The remaining remote-CI, downstream proof, unfamiliar-developer, legal/privacy, and publication gates are recorded there; do not collapse them into a generic “production ready” claim.
diff --git a/Gemfile b/Gemfile
new file mode 100644
index 0000000..1158387
--- /dev/null
+++ b/Gemfile
@@ -0,0 +1,66 @@
+# frozen_string_literal: true
+
+source "https://rubygems.org"
+
+# Runtime dependencies are specified in clickwrap.gemspec
+gemspec
+
+# Build & release tools
+gem "rake", "~> 13.0"
+
+group :development do
+ gem "appraisal"
+
+ # Code quality
+ gem "rubocop", "~> 1.0", require: false
+ gem "rubocop-minitest", "~> 0.35", require: false
+ gem "rubocop-performance", "~> 1.0", require: false
+end
+
+group :test do
+ gem "minitest", "~> 6.0"
+ # Minitest 6 extracted minitest/mock into its own gem.
+ gem "minitest-mock"
+ gem "mocha", "~> 2.0"
+ # Optional integration contract only. Devise is deliberately not a runtime
+ # dependency; this lane proves that choosing the adapter does not make it an
+ # untested README promise.
+ gem "devise", ">= 5.0.4", "< 6", require: false
+ # Pinned to the 0.x line: SimpleCov 1.0 deprecates `SimpleCov.start` from a
+ # `.simplecov` file, which is the layout the whole gem ecosystem uses.
+ gem "simplecov", "~> 0.22", require: false
+
+ # Optional integration contract only, like Devise above. The :markdown
+ # document renderer uses whichever Markdown library the HOST bundles
+ # (commonmarker, redcarpet, or kramdown) and adds no runtime dependency;
+ # kramdown is the pure-Ruby one, so it is the one the test lane exercises.
+ gem "kramdown", require: false
+
+ # Same posture for the :markdown_rails renderer, which renders documents
+ # through the HOST's own registered markdown-rails handler — the byte-parity
+ # contract the tests exercise. Never a runtime dependency.
+ gem "markdown-rails", require: false
+
+ # Rails frameworks the dummy app boots that are NOT runtime dependencies of
+ # the gem itself. Clickwrap never requires a job backend or a mailer, but the
+ # dummy app exercises the after-commit hook and the retention tasks against
+ # real Rails APIs, and Active Job's :test adapter is how we prove that an
+ # analytics or notification failure can never undo a committed action.
+ gem "actionmailer"
+ gem "activejob"
+
+ # Database adapters. SQLite is the default lane; PostgreSQL runs the
+ # concurrency, locking, and hardening lanes because that is where the
+ # advisory locks and update/delete protections actually differ.
+ gem "mysql2"
+ gem "pg"
+ gem "sqlite3", ">= 2.9.6"
+
+ # Dummy Rails app
+ gem "bootsnap", require: false
+ gem "propshaft"
+ gem "puma"
+
+ # Fix RDoc version conflict (Ruby 3.4+ ships with 7.0.3)
+ gem "rdoc", ">= 7.0"
+end
diff --git a/README.md b/README.md
index 2c4c40a..58c26af 100644
--- a/README.md
+++ b/README.md
@@ -1,571 +1,553 @@
-# ☑️ `clickwrap` — trustworthy agreements, consent, declarations, and authorizations for Rails
+# ☑️ `clickwrap` - Make your Rails users accept your Terms and legal documents
-> [!IMPORTANT]
-> **README-first product contract.** `clickwrap` is not implemented or published yet. This README deliberately describes the finished gem we intend to build so we can work backward from the ideal developer experience. Every public promise below is an acceptance criterion, not a claim about code that exists today. Remove this notice only after the implementation and proof integrations satisfy it.
+[](https://badge.fury.io/rb/clickwrap) [](https://github.com/rameerez/clickwrap/actions)
+
+> [!TIP]
+> **🚀 Ship your next Rails app 10x faster!** I've built **[RailsFast](https://railsfast.com/?ref=clickwrap)**, a production-ready Rails boilerplate template that comes with everything you need to launch a software business in days, not weeks — including versioned Terms and Privacy Notice acceptance powered by this gem. Go [check it out](https://railsfast.com/?ref=clickwrap)!
-`clickwrap` is the missing evidence-and-assent layer for Rails.
+`clickwrap` makes your Rails users accept your Terms of Service, acknowledge your Privacy Notice, give (and withdraw) consent, make declarations, and authorize one-time actions — and keeps evidence of all of it that you can still reproduce and verify years later.
-It makes ordinary Terms acceptance and its action one beautiful form-builder call:
+✨ Perfect for SaaS signups, marketplaces, fintech payouts, health apps, and any Rails app where "the user agreed to this" needs to be provable long after the fact.
+
+Ordinary Terms acceptance is one line in your signup form:
```erb
<%= form.clickwrap :signup, submit: "Create account" %>
```
-And it grows with you all the way to expiring declarations, withdrawable consent, one-time authorizations, exact historical receipts, transaction-bound evidence, retention, legal holds, and independently verifiable exports—without making the simple path feel complicated.
+…and it renders one line on the page. One checkbox, one sentence, your legal pages linked inside it:
+
+> ☐ I agree to the [Terms of Service](#) and I acknowledge the [Privacy Policy](#).
+
+No "Required" flag, no "(opens in a new tab)" printed beside every link, no version label sitting under a checkbox. Behind that single control, the receipt still records two separate acts — an *agreement* to the Terms and an *acknowledgment* of the Privacy Notice — with their own versions, digests, and lifecycles.
+
+And when an action is consequential enough that it must never happen without its evidence (a payout, a data handoff, a contract), the evidence and the action commit in the same database transaction:
```ruby
-receipt = Clickwrap.capture_and!(
- :withdrawal_authorization,
- actor: current_user,
- subject: withdrawal,
- http_request: request,
- submission: clickwrap_submission
-) do |pending_receipt|
+Clickwrap.capture_and!(:withdrawal_authorization, actor: current_user, subject: withdrawal,
+ http_request: request, submission: clickwrap_submission) do |pending_receipt|
withdrawal.submit!(authorized_by_clickwrap_event: pending_receipt.event_id)
end
```
-If evidence cannot be recorded, the protected database action does not happen. If the action fails, the evidence does not pretend it succeeded.
-
-No JavaScript package. No Redis. No external account. No legal-document vendor. No required per-event API call. No required background job. Just Rails, your database, and an API that reads like plain English.
+If the evidence can't be recorded, the action doesn't happen. If the action fails, the evidence doesn't pretend it succeeded.
-> [!TIP]
-> **Building a new Rails product?** [RailsFast](https://railsfast.com/?ref=clickwrap) ships the conventional signup integration, so new applications start with versioned Terms, a distinct Privacy Notice acknowledgment, atomic evidence, and receipts instead of inventing an `accepted_terms_at` column.
+No JavaScript package. No Redis. No background jobs. No external accounts or per-event API calls. No legal-document vendor. Just Rails, your database, and a DSL that reads like plain English.
-## The five-minute version
+> [!IMPORTANT]
+> **Status: built and tested, not yet proven in production.** Everything in this README is implemented and covered by the test suite, but the gem hasn't been through its planned production integrations, an unfamiliar-developer usability test, or legal review of its default wording yet. Treat it as a release candidate for evaluation — don't put it under a payout flow just yet. The [stability promise](#stability-and-upgrade-promise) applies from 0.1.0 onward.
-Install it:
+## 👨💻 Example
-```bash
-bundle add clickwrap
-bin/rails generate clickwrap:install
-bin/rails db:migrate
-```
+Define your documents and a policy in plain Ruby. Each document points at the file that *is* your legal text — and that file names its own version, in the front matter it probably already has. The top of `app/content/legal/terms.md`:
-The installer detects Rails authentication versus Devise, integer versus UUID primary keys, and the database adapter. It generates adaptive migrations, one annotated initializer, a conventional signup policy, and the correct explicit authentication integration. It never invents legal text or silently guesses an ambiguous actor model.
+```markdown
+---
+title: Terms of Service
+last_updated: 2026-08-15
+---
-Point the generated policy at the exact documents your application already owns:
+# Terms of Service
+```
```ruby
+# clickwrap-doc-test: syntax-only — terms.md and privacy.md are files in your app
# config/clickwrap.rb
Clickwrap.document :terms,
- version: "2026-08-15",
- from: Rails.root.join("app/content/legal/terms.md")
+ from: Rails.root.join("app/content/legal/terms.md"),
+ link: "/legal/terms"
Clickwrap.document :privacy_notice,
- version: "2026-08-15",
- from: Rails.root.join("app/content/legal/privacy.md")
-
-Clickwrap.policy :signup do
- agree_to :terms
- acknowledge :privacy_notice
-end
-```
-
-Tell Clickwrap which records can act:
-
-```ruby
-# app/models/user.rb
-class User < ApplicationRecord
- has_clickwraps
-end
+ from: Rails.root.join("app/content/legal/privacy.md"),
+ link: "/legal/privacy"
```
-Render the policy and its bound submit action:
-
-```erb
-<%= form_with model: resource do |form| %>
- <%# email, password, etc. %>
-
- <%= form.clickwrap :signup, submit: "Create account" %>
-<% end %>
-```
+`from:` is the bytes Clickwrap freezes, digests, and keeps as evidence. `link:` is where a *person* reads them — your own formatted page, with your typography and your navigation — and it is the path Clickwrap both renders and signs, so the receipt never cites a different target from the link somebody pressed. Leave `link:` off and the sentence links to the engine's rendering of the exact published version instead. (Your page shows whatever is current; that trade is yours to make, and it is written down in the declaration where a reviewer will see it.)
-Publish immutable snapshots and boot the app:
+Changing your Terms is then one edit in one file: new words, new `last_updated:`, publish. There is no second copy of the version label anywhere to drift — and a file carrying neither `clickwrap_version:` nor `last_updated:` fails the boot with a sentence instead of getting a label Clickwrap invented. Sources that can't carry front matter still name their label the explicit way:
-```bash
-bin/rails clickwrap:publish
+```ruby
+Clickwrap.document :handbook,
+ version: "2026-08-15",
+ from: Rails.root.join("app/content/legal/handbook.pdf")
```
-That is the whole conventional integration. The helper renders the initially unselected controls and the submit button as one presentation, so the exact call to action in the signed manifest is the one the user can press. The generated Rails-authentication or Devise adapter saves the account and required evidence in one database transaction.
-
-At first render there is no persisted user yet. Clickwrap does not pretend otherwise: it binds the presentation to a short-lived prospective-actor registration flow, then the authentication adapter binds the resulting account to that presentation inside the same transaction. The receipt identifies the attribution method as account registration, not an authenticated session.
+#### Reading that front matter yourself: `Clickwrap::FrontMatter`
-From that moment on:
+Your own pages usually need the same two answers, and it is the same block, so use the same reader rather than writing a third one:
```ruby
-user.clickwraps.agreed_to?(:terms) # => true
-user.clickwraps.acknowledged?(:privacy_notice) # => true
-user.clickwraps.current_for?(:signup) # => true
-
-receipt = user.clickwraps.receipts.last
-receipt.event_id # => "01K2..."
-receipt.verify.success? # => true
-receipt.to_canonical_json
-receipt.to_html
+Clickwrap::FrontMatter.version_label_in(File.read(path)) # => "2026-08-15", or nil
+Clickwrap::FrontMatter.strip(File.read(path)) # the body, without the block
```
-Clickwrap preserves the exact document bytes and digests, policy revision, assertion and link text, choices, submit-button text, locale, presentation manifest, actor, authentication context, server time, lifecycle, and resulting protected action. Optional request evidence stays off until you explicitly ask for it.
-
-Everything below is depth, not setup tax.
+It reads a leading `---` block closed by `---` or `...`, takes simple top-level `key: value` lines only, and answers with `clickwrap_version:` when present, `last_updated:` otherwise — a same-day correction that still changes bytes needs a fresh label while the date readers see stays put. Two details are exactly where hand-rolled readers diverge, so they are worth naming: a quoted value has its quotes removed, and an unquoted trailing YAML comment is not part of the value, so `last_updated: 2026-11-01 # was 2026-08-15` is the label `2026-11-01`, precisely as YAML reads it.
-If you came for one particular job:
+`strip` removes the block from the *rendered* representation only. The source digest still covers the exact file bytes, front matter included, because that is what the file was.
-- start with [the form helper](#the-form-helper) for ordinary Rails forms;
-- use [`capture_and!`](#capture-evidence-and-the-protected-action-together) for consequential same-database actions;
-- read [consent](#consent-that-can-actually-be-withdrawn), [declarations](#expiring-and-corrected-declarations), or [one-time authorization](#narrow-one-time-authorizations) for richer lifecycles;
-- configure [optional request evidence](#optional-request-evidence-private-by-default) only after reading its privacy boundaries;
-- use [receipts](#receipts-answer-show-me-exactly-what-happened), [retention](#retention-deletion-and-legal-holds-are-first-class), and [integrity tiers](#progressive-honest-integrity) when the audit trail matters; or
-- jump to [the complete initializer](#the-generated-initializer-explains-itself) to see every default together.
-
----
+Then say how long the evidence lives and what the server offers:
-## Why this gem exists
-
-A checkbox is easy. Answering these questions three years later is not:
-
-- Which exact version did this person agree to?
-- What did the page actually say beside the control and submit button?
-- Was the checkbox initially empty and required on the server?
-- Did the account, payout, declaration, or provider handoff succeed without its evidence?
-- Was this consent later withdrawn?
-- Had this declaration expired?
-- Did this authorization cover this exact transaction, or was it replayed for another one?
-- Can an auditor reproduce the document without checking out historical application code?
-- Can optional personal request evidence be deleted without rewriting the historical event?
-- Can the exported receipt still be verified after several gem and Rails upgrades?
+```ruby
+Clickwrap.retention :ordinary_agreement_evidence do
+ retain_core_event_for 6.years
+end
-Most applications eventually accumulate some combination of:
+Clickwrap.policy :signup do
+ agree_to :terms
+ acknowledge :privacy_notice
-```text
-accepted_terms_at
-terms_version
-an audit log
-a few hidden form fields
-an after_create callback
-some IP-address columns
-several domain-specific "confirmed_at" timestamps
+ retain_with :ordinary_agreement_evidence
+end
```
-Each part looks reasonable alone. Together they produce partial writes, client-owned policy decisions, mutable history, confused consent semantics, and evidence that only the original engineer can explain.
+(Yes, the payload-retention decision is mandatory — `clickwrap` will not silently
+default captured evidence or request evidence to "keep forever" and will not pick
+a period for you. A minimal, digest-linked disposition tombstone remains after a
+reviewed core deletion so the deletion itself does not become an unexplained hole.)
-`clickwrap` turns that recurring plumbing into one coherent Rails primitive:
+Add one macro to your model:
-```text
-immutable document
- +
-server-owned policy
- +
-exact presentation
- +
-explicit actor action
- +
-atomic protected outcome
- +
-append-only lifecycle
- =
-reproducible receipt
+```ruby
+class User < ApplicationRecord
+ has_clickwraps
+end
```
-It is intentionally not a “one checkbox makes anything legal” gem. It provides excellent evidence mechanics. Your application and counsel still own the words, lawful basis, fairness, capacity, authority, jurisdiction, formalities, and retention decisions.
-
-## Six verbs, six honest meanings
+Render the line and the submit button as one bound presentation:
-Not every checkbox is “consent,” and not every timestamp is a “signature.” Clickwrap gives each act the lifecycle it actually needs:
+```erb
+<%= form.clickwrap :signup, submit: "Create account" %>
+```
-| Policy verb | Evidence kind | Meaning | Typical lifecycle |
-|---|---|---|---|
-| `agree_to` | `agreement` | Assent to contractual terms | agreed → superseded/new version |
-| `acknowledge` | `acknowledgment` | Affirmative receipt or awareness of a notice/risk | acknowledged → superseded/expired |
-| `consent_to` | `consent` | Purpose-specific permission where consent is the host’s chosen basis | granted → withdrawn/renewed/scope changed |
-| `declare` | `declaration` | A factual statement made by the actor | declared → corrected/superseded/expired |
-| `attest` | `attestation` | An operational fact affirmed by an authorized actor | attested → corrected/superseded |
-| `authorize` | `authorization` | Narrow permission bound to a protected action | authorized → consumed/revoked/expired |
+> ☐ I agree to the [Terms of Service](#) and I acknowledge the [Privacy Policy](#).
-The DSL is intentionally verbal:
+From that moment on, you can ask readable questions everywhere:
```ruby
-Clickwrap.policy :example do
- agree_to :terms
- acknowledge :privacy_notice
- consent_to :product_updates, optional: true
- declare :information_is_accurate
- attest :bank_transfer_was_accepted
- authorize :withdrawal, one_time: true, valid_for: 10.minutes
-end
+user.clickwraps.agreed_to?(:terms) # => true
+user.clickwraps.acknowledged?(:privacy_notice) # => true
+user.clickwraps.current_for?(:signup) # => true
```
-The policy compiler rejects incoherent combinations at boot. A one-time authorization cannot be indefinite. Consent needs a withdrawal path. A declaration can expire without pretending the original statement was false. Withdrawing future consent never rewrites a historical agreement.
+And every acceptance produces a receipt you can export and verify — even outside your app, without your app's source code:
-This taxonomy is product design, not statutory vocabulary. The host chooses the correct kind with appropriate legal/product review.
+```ruby
+receipt = user.clickwraps.receipts.last
+receipt.verify.success? # => true
+receipt.to_canonical_json # canonical JSON for the standalone verifier
+receipt.to_html # human-readable version of the same evidence
+```
-One submitted policy produces one root evidence event and one receipt, even when the policy contains several acts. Each act keeps its own kind, statement, documents, answer, and lifecycle under that root event. That gives the protected domain action one stable `event_id` to reference without flattening “agreed to Terms” and “acknowledged the Privacy Notice” into the same meaning.
+Sounds good? Let's get started!
-## Documents are immutable, reproducible records
+## Quick start
-Define a logical document once and publish as many immutable versions and locales as needed:
+Add the gem and run the installer:
```ruby
-Clickwrap.document :terms,
- version: "2026-08-15",
- locale: :en,
- effective_at: Time.utc(2026, 8, 15),
- from: Rails.root.join("app/content/legal/terms.en.md")
-
-Clickwrap.document :terms,
- version: "2026-08-15",
- locale: :es,
- effective_at: Time.utc(2026, 8, 15),
- from: Rails.root.join("app/content/legal/terms.es.md")
+# Gemfile
+gem "clickwrap", github: "rameerez/clickwrap"
```
-Publish them during development or deployment:
-
```bash
-bin/rails clickwrap:publish
+bundle install
+bin/rails generate clickwrap:install
+bin/rails db:migrate
```
-Publishing:
-
-- reads the exact bytes;
-- records media type and locale;
-- calculates a versioned digest;
-- snapshots the exact rendered representation when a source format is transformed for display;
-- records the renderer and sanitizer identity/version used for that representation;
-- freezes a database snapshot;
-- compiles and freezes every policy revision that references it; and
-- refuses to reuse a version label for different bytes.
+`bundle add clickwrap` would install version 0.0.0, a deliberately empty name placeholder on RubyGems — install from GitHub until the first real version is published there.
-The task is idempotent. A changed document requires a new version. Export never fetches a mutable live URL and calls it historical evidence.
-
-Preview the plan without writing:
+The installer detects Rails authentication vs. Devise, integer vs. UUID primary keys, and your database adapter, then generates adaptive migrations, one annotated initializer, and a conventional signup policy. It emits only the tables your installation can actually write to; the capabilities that are off by default bring their own migration when you want them:
```bash
-bin/rails clickwrap:publish:plan
-```
-
-The default database store is deliberately boring and complete. Larger applications can switch document bodies to content-addressed Active Storage or object-lock storage while keeping the same digest and receipt contract:
-
-```ruby
-config.store_document_contents_in = :active_storage
+bin/rails generate clickwrap:install --with-request-evidence # the IP / user-agent / geolocation annex
+ --with-integrity # event chaining, anchoring, timestamps
+ --with-retention-ops # legal holds and disposition plans
+ --with-external-actions # the outbox for external handoffs
+ --with-persisted-presentations
```
-Every storage adapter must return immutable bytes plus a verifiable digest. A URL alone is never a document version.
+Add any of them later by re-running the generator with the flag. Turning a capability on without its migration is caught at boot, by `bin/rails clickwrap:doctor`, and at the call itself — always with the exact command that fixes it. Enabling a request-evidence field brings the annex table automatically, because an installation that records IP addresses into a table it never created is not a schema choice. If your legal pages already live in the app, it points `from:` at those exact files and writes no `version:` line — the pages name their own versions. It never invents legal text and never silently guesses your actor model.
-Markdown, HTML, plain text, and attached files are evidence inputs, not trusted markup by accident. The reference renderer sanitizes display HTML. A custom renderer must return the exact rendered bytes it offered, and Clickwrap stores their digest alongside the original-source digest. That preserves the distinction between “this Markdown file existed” and “this rendered representation was offered.”
-
-## Policies are server-owned offers
-
-A policy declares what the server will present and accept. The browser may answer; it may never choose the policy, document version, validity, subject, retention, or request-evidence fields.
-
-```ruby
-Clickwrap.policy :driver_declaration do
- declare :non_professional_driver,
- document: :driver_declaration,
- statement: "I declare that I drive privately and not as a professional driver.",
- valid_for: 1.year,
- subject_fingerprint_with: ->(scheme) { scheme.evidence_fingerprint }
+Point the generated policy at the documents your app already owns (see the example above), add `has_clickwraps` to your user model, and drop `form.clickwrap` into your signup form:
- retain_with :regulated_evidence
-end
+```erb
+<%= form.clickwrap :signup, submit: "Create account" %>
```
-Policies compile at boot. Clickwrap fails loudly for:
-
-- missing documents or locales;
-- duplicate statement keys;
-- invalid lifecycle options;
-- a consent policy without a configured withdrawal path;
-- a one-time authorization without expiry/consumption behavior;
-- request evidence without a named present purpose and retention decision;
-- a subject-bound policy without a subject fingerprint; or
-- a changed compiled policy reusing the same revision.
-
-Policy revisions are defined pleasantly in Ruby and persisted as frozen canonical snapshots. Historical receipts do not need current source code to explain what revision meant.
-
-Every human-facing value can be a literal, an I18n key, or a locale map. Clickwrap resolves it before presentation, fails closed when a required translation is missing, and stores the resolved text and locale—not merely an I18n key whose meaning may change later.
-
-### Reacceptance is explicit
-
-New document bytes do not silently reinterpret old evidence:
+Then wire the door that creates the account, because the form is only half the circuit — some line has to write the account and its evidence in the same transaction:
```ruby
-Clickwrap.policy :current_terms do
- agree_to :terms, require_current_version: true
+# Devise — app/controllers/users/registrations_controller.rb
+class Users::RegistrationsController < Devise::RegistrationsController
+ clickwraps_registration_with :signup
end
```
```ruby
-Clickwrap.required?(:current_terms, actor: user) # => true after a new version publishes
-user.clickwraps.current_for?(:current_terms) # => false
+# Rails authentication, an OAuth finish screen, a service object — any door
+# that builds the record itself.
+unless register_with_clickwrap(:signup, user: @user) { @user.save! }
+ return render :new, status: :unprocessable_entity
+end
```
-The application decides which change is material. Clickwrap enforces the rule it is given; it does not decide legal materiality.
+Do not skip that step. Leave it out and everything still *looks* right — the checkbox renders, the person ticks it, the account is created — and there is no evidence at all. It is the one omission this gem cannot warn you about at runtime, because an app with no door simply never calls it.
-Before activating a new required version, operators can preview its effect:
+Finally, publish immutable snapshots of your documents:
```bash
-bin/rails clickwrap:reacceptance:plan POLICY=current_terms
+bin/rails clickwrap:publish
```
-The plan reports affected actor counts and configured remediation routes without emailing anyone, changing current state, or calling the change “material.” Scheduled versions become presentable only at their explicit `effective_at`; correcting a published mistake means publishing a new version or stopping future presentation with an append-only operator reason, never replacing historical bytes.
+That's the only time you run that by hand: publishing rides `db:prepare`, so a deploy that runs
+it also freezes the snapshots for whatever you declared, before the server takes traffic
+(`config.publish_documents_after_database_preparation = false` if you'd rather own the step).
-## Presentation manifests stop render-to-submit substitution
+That's it! Your app now records which exact document versions the server offered, which explicit
+answers it accepted, the bound presentation wording, and when—atomically with account creation.
+Let's see how it works.
-`form.clickwrap` does more than render controls. It creates a short-lived presentation manifest bound to an actor or prospective-actor flow, subject, and tenant containing:
+### What that one line renders
-- policy key and frozen revision;
-- document versions, locales, and digests;
-- exact statements, labels, link labels/targets, choices, required state, and CTA text;
-- actor, tenant, and subject bindings;
-- subject fingerprint;
-- template, application, and gem versions;
-- capture channel;
-- issue time, expiry, and one-use nonce; and
-- a canonical manifest digest.
+One line:
-The browser receives a signed presentation token. On submit, Clickwrap verifies it against current server policy and rejects stale, swapped, expired, cross-account, cross-tenant, or cross-subject tokens.
+> ☐ I agree to the [Terms of Service](#) and I acknowledge the [Privacy Policy](#).
-A deploy between GET and POST never causes the server to record a version the actor was not offered. The policy either honors that still-valid presentation or asks the user to review the new one.
+One checkbox, one label, one sentence, with the documents linked *inside* it. The label **is** the
+line, so pressing the words toggles the control and a screen reader announces the sentence and the
+box together. There is no "Required" flag, no "(opens in a new tab)" printed beside every link, and
+no version label under the checkbox. (The `required` attribute is still there as progressive
+enhancement — **the server decides** — the "opens in a new tab" truth is still announced to screen
+readers when the link really does open one, and versions still appear on receipts, where somebody
+is actually reading the record.)
-The default signed-manifest path performs no database write on GET. A high-assurance flow can explicitly retain pre-submit presentation attempts:
+Behind that single control the evidence is unchanged: two statements, two kinds, two document
+versions, two lifecycles. Ticking the box records an *agreement* to the Terms and an
+*acknowledgment* of the Privacy Notice; leaving it empty refuses both. The manifest signs the exact
+composed sentence and which statements the one control answered, so the substitution defense holds
+over the wording a person actually read.
+
+Clickwrap composes that line only when every statement in the policy is an ordinary, required,
+default-worded `agree_to` or `acknowledge`. Anything else keeps a control of its own, **below** the
+line:
```ruby
-Clickwrap.policy :regulated_authorization do
- persist_presentations_before_submission_for 30.days,
- because: "Investigate disputes about this regulated authorization"
- authorize :regulated_action, one_time: true, valid_for: 10.minutes
-end
-```
+Clickwrap.policy :signup do
+ # These two compose into the line.
+ agree_to :terms, link_label: "Terms of Service"
+ acknowledge :privacy_notice, link_label: "Privacy Policy"
-Persisted presentations carry their own purpose, access, abuse controls, and retention; an abandoned GET is labeled `presented_by_server`, never `accepted` or `seen_by_human`.
+ # This one gets its own box, below the line, with its withdrawal route.
+ consent_to :product_updates,
+ document: :marketing_notice,
+ optional: true,
+ withdrawal_path: "/settings/privacy"
-The receipt says exactly what this proves: the server generated and accepted a particular presentation manifest. It does not claim the person read the document, understood it, saw particular pixels, or received a legally sufficient interface in every jurisdiction.
+ retain_with :ordinary_agreement_evidence
+end
+```
-### The form helper
+An optional consent is never folded in — bundling it would silently make it required, and unbundled
+consent is the whole point of the `consent_to` verb. Neither is a recorded yes/no, a statement with
+a withdrawal route, or copy your application wrote itself. And a policy with nothing composable —
+the operator attestation rails, the payout authorization — renders exactly as it always has, one
+control per act.
-The strongest happy path is one line because the component owns both the controls and the action whose wording it records:
+Want the itemized shape anyway? One boolean, and it reaches the presenter, so the manifest signs
+the shape that was actually offered:
```erb
-<%= form.clickwrap :signup, submit: "Create account" %>
+<%= form.clickwrap :signup, submit: "Create account", combined: false %>
```
-Submit options remain ordinary Rails:
+The words are yours. `clickwrap.sentence.agreement` and `clickwrap.sentence.acknowledgment` are
+ordinary translations with `%{documents}` marking where the links go, and each document's link text
+comes from `link_label:` on the statement — which is how "Privacy Notice" becomes "Privacy Policy"
+without touching what the statement asserts.
-```erb
-<%= form.clickwrap :signup,
- actor: current_user,
- subject: @organization,
- locale: I18n.locale,
- submit: {
- text: "Create organization",
- class: "button button--primary",
- data: { turbo_submits_with: "Creating…" }
- } %>
-```
+Legal pages in Markdown? `config.document_renderer = :markdown` renders through whichever
+Markdown library you already bundle, and `:markdown_rails` renders through your application's
+*own* registered markdown-rails renderer — the exact pipeline your public `/legal` pages go
+through, so the snapshot people accept comes out byte-for-byte identical to the rendered text
+those pages serve, by construction rather than by careful copying.
-The helper renders:
+Wiring the gem into an existing production app — or handing the job to an AI agent? The
+[integrating guide](guides/integrating.md) is the step-by-step playbook from a full
+production migration, in the exact order that avoids every mistake we made.
-- real, initially unselected controls;
-- kind-appropriate first-person language;
-- obvious document links before the submit action;
-- stable label/control/error associations;
-- server errors and accessible error summaries;
-- the signed presentation token; and
-- no hidden IP address, browser user-agent, policy version, validity date, or other client-owned security decision.
+Hotwire Native? One setting answers both halves of the native question — the href
+*and* the link attributes:
-HTML `required` is progressive enhancement. Server validation is always authoritative.
+```ruby
+Clickwrap.configure do |config|
+ config.hotwire_native_document_links = {
+ open_in: :external_browser,
+ canonical_host: "https://www.example.com"
+ }
+end
+```
-If your design system needs to render the action separately, use the deliberately explicit split API:
+Here's why that matters: on a native authentication sheet, a same-host document
+link is routed by the app itself, which pops the sheet and takes the half-filled
+signup form with it. `:external_browser` absolutizes the signed document path
+against your canonical host and opens it outside the WebView, so the form is
+still there when the person comes back. `:same_screen` keeps a plain same-host
+link for your own native path configuration to route (a document sheet inside a
+signed-in funnel, say).
-```erb
-<%= form.clickwrap_fields :signup,
- submit_button_text: "Create account" %>
+One app often needs both — the auth sheet must escape, the signed-in funnel
+routes its own sheet — so `open_in:` also takes a callable:
-<%= form.submit "Create account" %>
+```ruby
+config.hotwire_native_document_links = {
+ open_in: ->(controller) { controller.signing_up? ? :external_browser : :same_screen },
+ canonical_host: "https://www.example.com"
+}
```
-The repeated text is intentional: it makes the evidence contract visible in code. Development and system-test assertions compare the declared text with the rendered submit control and reject a mismatch. The one-call API is preferred because it makes that class of drift impossible.
+It is asked once when the href is signed and once when the link is rendered,
+with the same controller both times, so the two halves of a link cannot
+disagree.
-### Use the ready-made standalone remediation screen
+Another client needs different attributes, or different ones per screen? Keep the
+gem's canonical partial and set `config.document_link_html_options_with`. It can
+add `data: { turbo: false }`, `target`, or `rel`; it cannot replace the immutable
+`href` that Clickwrap signs into the presentation. When the native setting above
+is set it answers native renders entirely, and this hook goes on answering every
+other render.
-Any policy can be completed outside its original flow:
+## How it works
-```ruby
-# config/routes.rb
-mount Clickwrap::Engine => "/agreements"
-```
+Most apps eventually accumulate an `accepted_terms_at` column, a `terms_version` string, a few hidden form fields, an `after_create` callback, and some IP columns. Each part looks reasonable alone. Together they produce partial writes, client-owned policy decisions, mutable history, and evidence only the original engineer can explain.
-```ruby
-clickwrap_capture_path(:driver_declaration)
-```
+`clickwrap` replaces that plumbing with one coherent primitive:
-The engine provides actor-owned capture, receipt, consent-withdrawal, and document-history surfaces using your parent controller, layout, locale, and authorization callbacks. This makes a required agreement or declaration resolvable in place instead of becoming a dead end.
+1. **Documents are immutable.** Publishing reads the exact bytes, digests them, and freezes a snapshot. A changed document requires a new version — the task refuses to reuse a version label for different bytes.
+2. **Policies are server-owned.** The browser may answer; it may never choose the policy, document version, validity, subject, or what gets recorded. Policies compile at boot and fail loudly when misconfigured.
+3. **Presentations are signed.** `form.clickwrap` creates a short-lived signed manifest of what the server generated for the form: documents, digests, statements, choices, and the submit button text. Stale, swapped, expired, or cross-account tokens are rejected at submit. A deploy between render and submit cannot record a version that was not bound to the accepted submission. This does not prove human perception or comprehension.
+4. **Capture is atomic.** Evidence and the protected database action commit together or not at all. Replays of the same submission return the original result instead of running twice.
+5. **Lifecycle history appends.** Through Clickwrap's public/model APIs, withdrawal, expiry, correction, and supersession append new events instead of rewriting the earlier event. Optional PostgreSQL hardening rejects additional direct database mutation paths; the integrity verifier detects covered changes rather than pretending a fully privileged database actor is impossible.
+6. **Receipts have a standalone verifier.** Canonical JSON ([RFC 8785](https://www.rfc-editor.org/rfc/rfc8785)) with versioned schemas and SHA-256 digests can be checked by the bundled `clickwrap` CLI without booting Rails. The result distinguishes fully verified, failed, and incomplete checks; document-byte checks need the exported artifacts, and reviewed disposition is reported as disposition rather than ordinary verification.
-### Eject or fully own the UI
+## Six verbs, six honest meanings
-Copy the tested reference views:
+Not every checkbox is "consent," and not every timestamp is a "signature." Each verb gets the lifecycle it actually needs:
-```bash
-bin/rails generate clickwrap:views
-```
-
-Your copies shadow the gem’s views. Tailwind, Bootstrap, ViewComponent, Phlex, custom design systems, and plain ERB are all welcome.
+| Policy verb | Meaning | Typical lifecycle |
+|---|---|---|
+| `agree_to` | Assent to contractual terms | agreed → superseded by new version |
+| `acknowledge` | Affirmative receipt of a notice or risk | acknowledged → superseded / expired |
+| `consent_to` | Purpose-specific permission | granted → withdrawn / renewed |
+| `declare` | A factual statement made by the actor | declared → corrected / expired |
+| `attest` | An operational fact affirmed by an operator | attested → corrected / superseded |
+| `authorize` | Narrow permission bound to one protected action | authorized → consumed / expired |
-For a completely custom surface, ask the presenter for primitives rather than recreating hidden inputs:
+The DSL is intentionally verbal:
```ruby
-presentation = Clickwrap.present(
- :signup,
- actor: current_user,
- subject: nil,
- locale: I18n.locale,
- submit_button_text: "Create account"
-)
+Clickwrap.policy :example do
+ agree_to :terms
+ acknowledge :privacy_notice
+ consent_to :product_updates, optional: true, withdrawal_path: "/settings/privacy"
+ declare :information_is_accurate
+ attest :bank_transfer_was_accepted
+ authorize :withdrawal, one_time: true, valid_for: 10.minutes
+
+ retain_with :ordinary_agreement_evidence
+end
```
-```erb
-<%= hidden_field_tag "clickwrap_submission[presentation_token]", presentation.token %>
+The policy compiler rejects incoherent combinations at boot, in full sentences that tell you what's wrong and what to do about it: a one-time authorization can't be indefinite, consent needs a withdrawal path, and withdrawing future consent never rewrites a historical agreement.
-<% presentation.statements.each do |statement| %>
- <%# Render statement.control_name, label, document links, choices and errors. %>
-<% end %>
-```
+## Protect an action with its evidence
-The development linter compares the submitted manifest with the policy/presenter contract and warns about missing statements, preselected consent, absent links, controls placed after the CTA, or unregistered custom copy. It reports objective problems; it never prints “legally compliant.”
+`capture_and!` is the gem's signature move. In one supported database transaction it verifies the presentation, appends the evidence event, yields to your domain action, records the outcome, and commits both together:
-## Capture evidence and the protected action together
+Declare the exact post-action snapshot once. The callback receives the value
+returned by the protected-action block—not the pre-action subject—and
+`Clickwrap.protected_outcome` owns the stable reference and canonical
+fingerprint:
-For an existing actor in a normal Rails controller:
+```ruby
+Clickwrap.policy :withdrawal_authorization do
+ authorize :withdrawal,
+ one_time: true,
+ valid_for: 10.minutes,
+ protected_outcome_version: "submitted-withdrawal-v1",
+ record_protected_outcome_with: lambda { |withdrawal|
+ Clickwrap.protected_outcome(
+ action: :submitted,
+ record: withdrawal,
+ state: withdrawal.status,
+ facts: {
+ amount_in_cents: withdrawal.amount_cents,
+ currency: withdrawal.currency,
+ destination_reference: withdrawal.destination_reference
+ }
+ )
+ }
+
+ retain_with :regulated_evidence
+end
+```
```ruby
def create
withdrawal = current_user.withdrawals.build(withdrawal_params)
- receipt = capture_clickwrap_and!(
- :withdrawal_authorization,
- actor: current_user,
- subject: withdrawal
- ) do |pending_receipt|
+ capture_clickwrap_and!(:withdrawal_authorization, actor: current_user, subject: withdrawal) do |pending_receipt|
withdrawal.submit!(authorized_by_clickwrap_event: pending_receipt.event_id)
+ withdrawal # the exact completed result given to the outcome recorder
end
redirect_to withdrawal
end
```
-The controller helper reads only the generated `clickwrap_submission` envelope and the current `http_request`. It delegates to the same public service API:
-
-```ruby
-receipt = Clickwrap.capture_and!(
- :withdrawal_authorization,
- actor: current_user,
- subject: withdrawal,
- http_request: request,
- submission: clickwrap_submission
-) do |pending_receipt|
- withdrawal.submit!(authorized_by_clickwrap_event: pending_receipt.event_id)
-end
-```
+If the event write fails, the action rolls back. If your block raises, the event rolls back. Repeating an identical submission returns the original result without running the block twice; a conflicting replay fails with a stable `Clickwrap::ReplayRejected`. That remains true when the successful action itself changes the fingerprinted subject: once the signed nonce committed, replay verifies the frozen event context and exact answers instead of requiring the old pre-action state to still exist.
-Within one supported database transaction, Clickwrap:
+Link the row to the evidence that authorized it, so the connection survives years and engineers:
-1. verifies actor, tenant, subject, presentation, policy, document digests, answers, expiry, and nonce;
-2. acquires the required idempotency/subject locks;
-3. appends the pending evidence event;
-4. yields its receipt to the protected domain action;
-5. records the resulting outcome and consumes one-time authorization where applicable;
-6. commits both together; and
-7. invokes optional notifications/analytics only after commit.
-
-If the event write fails, the protected action rolls back. If the block raises, the event rolls back. Repeating an identical idempotency key returns the original result without running the block twice. A conflicting replay fails with a stable `Clickwrap::ReplayRejected` result.
+```bash
+bin/rails generate clickwrap:link withdrawals && bin/rails db:migrate
+```
-The block receives a read-only `Clickwrap::PendingReceipt`. Its stable `event_id` can be stored by the domain row, but export/verification methods are unavailable until commit. `capture_and!` returns the finalized `Clickwrap::Receipt`; if the transaction rolls back, the pending object becomes invalid instead of masquerading as committed evidence.
+```ruby
+class Withdrawal < ApplicationRecord
+ has_clickwrap_evidence policy: :withdrawal_authorization,
+ statement: :withdrawal,
+ actor: :user,
+ subject: :self
+end
-Atomic commit does not give Clickwrap permission to guess what a host method meant. Without a configured outcome snapshot, the receipt says only that the named policy, bound subject, evidence event, and block committed together. `record_protected_outcome_with` can add an exact post-action reference/state/fingerprint; it runs and validates inside the transaction, and a failure rolls the whole operation back.
+capture_clickwrap_and!(:withdrawal_authorization, actor: current_user, subject: withdrawal) do |pending_receipt|
+ withdrawal.clickwrap_event_id = pending_receipt.event_id
+ withdrawal.save!
+ withdrawal
+end
-The transaction contract is documented precisely for ownership, nested transactions, savepoints, deadlock/serialization retries, idempotency, callbacks, and after-commit behavior. Automatic retries occur only when Clickwrap can prove the block is safe to retry; otherwise a stable retryable error returns control to the host. Clickwrap never promises atomicity across two independent systems.
+withdrawal.clickwrap_receipt.verify.success? # one line, years later
+```
-### Capture without a protected action
+When the protected domain row needs the person's submitted choice, read it
+from the pending receipt rather than parsing controller params a second time:
```ruby
-receipt = Clickwrap.capture!(
- :current_terms,
- actor: current_user,
- http_request: request,
- submission: clickwrap_submission
-)
+capture_clickwrap_and!(:privacy_preferences, subject: membership) do |pending_receipt|
+ membership.show_on_public_profile =
+ pending_receipt.granted?(:public_profile_visibility)
+ membership.save!
+end
```
-### Devise and Rails authentication
+`answer_for`, `answered?`, `granted?`, and `declined?` read the validated,
+server-bound event being committed. An optional control left unselected returns
+`nil`/`false`; a statement name the policy never declared raises. Silence can
+therefore never become permission, while a typo cannot silently disable a
+feature. This keeps the browser's raw params out of protected domain logic.
-The installer detects the authentication stack and generates an explicit adapter—not a hidden `after_create` callback.
+This model-first deployment order is safe. Before the generated column exists,
+`has_clickwrap_evidence` stays inert and `clickwrap_receipt` returns `nil`; as
+soon as the migration adds `clickwrap_event_id`, every new row is fail-closed
+by default. That also lets historical data migrations replay schemas from
+before Clickwrap without loading a model method for a column that did not yet
+exist. It does not weaken current rows: after the column exists, missing,
+mismatched, or replaced links fail validation.
-For Devise, the generated controller reads:
+And when a *person* causes the refusal — a stale token, a required box left unticked — every such case is one exception family carrying a sentence you can actually show them:
```ruby
-class Users::RegistrationsController < Devise::RegistrationsController
- clickwraps_registration_with :signup
+def create
+ # ... capture_clickwrap_and! as above ...
+rescue Clickwrap::CaptureRefused => refusal
+ redirect_to new_withdrawal_path, alert: refusal.user_facing_message, status: :see_other
end
```
-For Rails’ authentication generator, the generated registration command uses:
+Or drop the bang and let it read like `save`. `capture_clickwrap_and` and
+`capture_clickwrap` absorb exactly that family, return `false`, put the
+per-statement message beside the control it belongs to, and leave the whole
+refusal on `clickwrap_refusal`:
```ruby
-register_with_clickwrap :signup, user: @user do
- @user.save!
+def create
+ receipt = capture_clickwrap_and(:withdrawal_authorization, subject: withdrawal) do |pending_receipt|
+ withdrawal.submit!(authorized_by_clickwrap_event: pending_receipt.event_id)
+ end
+
+ unless receipt
+ flash.now[:alert] = clickwrap_refusal.user_facing_message
+ return render :new, status: :unprocessable_entity
+ end
+
+ redirect_to withdrawal
end
```
-Both integrations ensure account activation and required evidence commit together. Emails, sign-in, redirects, and after-commit side effects occur only after the transaction has succeeded. A failed evidence write never leaves a normal public account silently active.
+Nothing else is absorbed, by either form. Infrastructure failures stay outside that family and stay loud: an evidence write that fails refuses the protected action instead of being swallowed. So do lifecycle conflicts — a conflicting replay (`Clickwrap::ReplayRejected`) or an already-consumed one-time authorization (`Clickwrap::OneTimeAuthorizationConflict`) still raises, because "this was already done" needs a domain answer that no generic rescue can supply honestly.
-Signup is modeled honestly as a prospective-actor flow:
+That atomicity has one exact boundary: Clickwrap's event and the protected domain
+write must use the same database connection. If a host model uses another Rails
+database/connection, its transaction cannot commit atomically with Clickwrap's
+tables. Put Clickwrap on the same connection for database-local work; use an
+explicit outbox/reconciliation design for another database or service.
-1. the GET creates a short-lived, signed registration-flow identifier;
-2. the presentation token binds to that flow, the form object type, and any host-selected tenant—not to a fictional persisted or authenticated user;
-3. the adapter validates the submitted presentation before account activation;
-4. one transaction persists the account, binds its stable actor reference to the evidence, and commits both; and
-5. the receipt records `account_registration` attribution and the actual pre-registration authentication state.
+For capture without a protected action, use `Clickwrap.capture!`. For external providers (Stripe, identity services) that can't share your database transaction, use `Clickwrap.authorize_external_action!` — a pending authorization plus idempotent outbox, so a provider timeout never becomes a fictional success or a double debit.
-Email addresses, passwords, and raw signup fields are not copied into the token. A token from another browser flow, tenant, form object, or already-created account is rejected. Applications that own a custom registration service use the same primitive directly:
+Controllers get the ambient actor, tenant, request, authentication context, and
+submitted presentation automatically:
```ruby
-receipt = Clickwrap.register!(
- :signup,
- prospective_actor: @user,
- http_request: request,
- submission: clickwrap_submission
-) do
- @user.save!
-end
+authorization = authorize_clickwrap_external_action!(
+ :identity_provider_handoff,
+ subject: verification,
+ provider_name: "identity_provider"
+)
```
-`register!` returns the same receipt type as `capture_and!`; the authentication adapters are thin conveniences over it.
-
-### External providers use an outbox, not pretend-ACID
-
-Stripe, identity services, timestamp providers, and remote signatures cannot share your database transaction. Use a pending authorization and idempotent outbox:
+If a migration requires a legacy/domain projection to commit with the event and
+pending outbox row, use the deliberately named local-transaction callback (or
+the equivalent block form):
```ruby
-authorization = Clickwrap.authorize_external_action!(
+authorization = authorize_clickwrap_external_action!(
:identity_provider_handoff,
- actor: current_user,
subject: verification,
- http_request: request,
- submission: clickwrap_submission
-)
-
-ProviderHandoffJob.perform_later(
- authorization_id: authorization.id,
- idempotency_key: authorization.idempotency_key
+ provider_name: "identity_provider",
+ after_pending_action_is_saved_inside_transaction: lambda do |pending_action:, pending_receipt:|
+ LegacyAuditLog.create!(
+ event_id: pending_receipt.event_id,
+ external_action_id: pending_action.id
+ )
+ end
)
```
+It runs once, only on initial capture, inside that local database transaction;
+if it raises, all three local writes roll back. Never call the provider or do
+network work there. The provider call starts only after the helper returns.
+
+### One-time, subject-bound authorizations
+
```ruby
-authorization.record_provider_success_and_consume!(provider_receipt)
+Clickwrap.policy :withdrawal_authorization do
+ acknowledge :withdrawal_requirements
+
+ declare :coverage_exclusivity,
+ subject_fingerprint_version: "covered-orders-v1",
+ subject_fingerprint_with: ->(withdrawal) { withdrawal.covered_orders_fingerprint }
+
+ authorize :withdrawal,
+ one_time: true,
+ valid_for: 10.minutes,
+ requires: %i[withdrawal_requirements coverage_exclusivity]
+
+ retain_with :regulated_evidence
+end
```
-That final method is one idempotent local transaction. Failures and ambiguous timeouts use `record_provider_failure!` and `record_provider_outcome_unknown!`; the reconciliation task can safely resolve them later. A provider timeout never becomes a fictional success or a second debit.
+The authorization is locked and consumed in the same transaction as the withdrawal. Another withdrawal, a changed subject, a stale declaration, or a concurrent replay cannot reuse it. This is the difference between "the user once accepted something" and "this exact evidence authorized this exact operation."
## Ask readable questions everywhere
@@ -573,407 +555,202 @@ The actor proxy is the everyday API:
```ruby
user.clickwraps.current_for?(:signup)
-user.clickwraps.required_for?(:current_terms)
user.clickwraps.agreed_to?(:terms)
-user.clickwraps.acknowledged?(:privacy_notice)
user.clickwraps.consented_to?(:product_updates)
-user.clickwraps.declared?(:non_professional_driver, subject: scheme)
+user.clickwraps.declared?(:independent_contractor, subject: scheme)
user.clickwraps.authorized?(:withdrawal, subject: withdrawal)
```
-Every predicate has a structured form when “no” needs an explanation:
+When "no" needs an explanation, `verify` returns a structured result with a stable error symbol (`:declaration_expired`, `:consent_withdrawn`, `:wrong_subject`, …), a matching predicate, and a localized message — you never parse English to make an authorization decision. `Clickwrap.require!` raises a typed error carrying the same result. Service boundaries read aloud:
```ruby
-result = Clickwrap.verify(
- :withdrawal_authorization,
- actor: user,
- subject: withdrawal
-)
+preparation = Clickwrap.verify(:withdrawal_preparation, actor: user,
+ require_current_revision: true)
+declaration = Clickwrap.verify(:coverage_exclusivity, actor: user, subject: user,
+ require_current_revision: true)
-result.success? # => false
-result.error # => :declaration_expired
-result.message # localized human explanation
-result.event_id
-result.details # stable machine-readable facts, no surprise PII
+declaration.stale_policy_revision? # legal reworded it → re-ask
+declaration.subject_fingerprint_mismatch? # what it covers changed since capture
+declaration.recorded_after?(preparation) # ordering enforced, not assumed
```
-Stable errors cover wrong actor/tenant/subject, stale policy, unseen document version, missing answer, expiry, withdrawal, predecessor/order, fingerprint mismatch, consumption, replay, and integrity failure.
+`recorded_after?` answers from a database-assigned recording sequence, so it stays true across actors, application processes, and same-microsecond writes — ULID lexical order is deliberately not used as chronology. Read its `false` carefully: it means "not after", **or** that one of the two has no sequence at all, which is the case for evidence recorded before the ordering migration and for a missing event. An upgrade cannot invent honest order for rows written before it, so `false` is the answer it gives rather than a guess. Branch on it as a guard (`return unless declaration.recorded_after?(preparation)`), never as proof of the opposite.
-The convention is consistent: predicates answer booleans, `verify` returns a result, and bang methods raise a typed error carrying that same result. Applications never need to parse an English error message to make an authorization decision.
+`require_current_revision: true` fails evidence recorded under a superseded policy revision, so "we changed the wording, everyone re-accepts" is one keyword instead of a hand-rolled revision comparison.
-### Controller gates that always have remediation
+The same call takes an event id, which is how you re-ask about one specific recorded act years later:
```ruby
-class BillingController < ApplicationController
- requires_clickwrap :current_terms, only: :show
-end
+Clickwrap.verify(event_id, subject: order_batch, require_current_revision: true)
```
-The gate redirects HTML/Hotwire users to the mounted policy capture screen and returns them to the original safe destination after completion. API clients receive a structured `clickwrap_required` response with a presentation endpoint.
+Both keywords mean exactly what they mean above: `subject:` re-derives the fingerprint from the record as it is *now*, and `require_current_revision:` compares the act's recorded revision against the wording compiled today. That is the complete "is this old evidence still good?" question, so nothing needs to reach into `Clickwrap::PolicyRevision` or `Clickwrap::SubjectFingerprint` to ask it. If the policy is no longer declared at all, the result says `:unknown_policy` — "we can no longer check this" never gets spelled the same way as "this is fine".
-A required gate must have a remediation route or an explicit host support fallback. Clickwrap refuses to compile a dead-end gate.
-
-Security-sensitive services should still verify at the domain boundary:
+Controller gates redirect users to a ready-made remediation screen and bring them back when they're done:
```ruby
-Clickwrap.require!(
- :withdrawal_authorization,
- actor: user,
- subject: withdrawal
-)
+class BillingController < ApplicationController
+ requires_clickwrap :current_terms, only: :show
+end
```
-Controller gates improve flow; service verification protects the action.
-
-## Consent that can actually be withdrawn
-
-Consent is purpose-specific, initially unselected, and separate from Terms or a Privacy Notice acknowledgment:
+### Reacceptance when documents change
```ruby
-Clickwrap.document :marketing_notice,
- version: "2026-08-15",
- from: Rails.root.join("app/content/legal/marketing.md")
-
-Clickwrap.policy :marketing_preferences do
- consent_to :product_updates,
- document: :marketing_notice,
- optional: true,
- withdrawal_path: "/settings/privacy"
-
- consent_to :partner_offers,
- document: :marketing_notice,
- optional: true,
- withdrawal_path: "/settings/privacy"
+Clickwrap.policy :current_terms do
+ agree_to :terms, require_current_version: true
- retain_with :marketing_consent_evidence
+ retain_with :ordinary_agreement_evidence
end
```
-Leaving an optional checkbox unselected creates no consent grant. The capture receipt can show that the option was offered and not granted, but it does not call silence an affirmative refusal. A policy that truly needs a recorded yes/no choice uses explicit unselected controls:
+Publish a new version and `current_for?` flips to `false` for everyone who accepted the old one. Preview the blast radius before you activate it:
-```ruby
-consent_to :research_contact,
- choices: { yes: :grant, no: :decline },
- require_an_explicit_choice: true,
- withdrawal_path: "/settings/privacy"
+```bash
+bin/rails clickwrap:reacceptance:plan POLICY=current_terms
```
-```ruby
-Clickwrap.withdraw!(
- :product_updates,
- actor: current_user,
- http_request: request,
- because: "The user withdrew this purpose in privacy settings"
-)
-```
+## Consent that can actually be withdrawn
-Withdrawal appends an event; it never deletes or mutates the historical grant. The policy’s post-commit hook can stop future processing or enqueue host-owned deletion work without making the original transaction depend on an analytics/job backend.
+Consent is purpose-specific, initially unselected, and separate from Terms:
```ruby
-config.after_event_is_committed = lambda do |event|
- Marketing::StopProcessingJob.perform_later(event.actor_id) if event.consent_was_withdrawn?
-end
-```
-
-Clickwrap structurally requires an accessible withdrawal path. It does not decide whether consent is the correct lawful basis.
-
-## Expiring and corrected declarations
+Clickwrap.policy :marketing_preferences do
+ consent_to :product_updates, optional: true, withdrawal_path: "/settings/privacy"
+ consent_to :partner_offers, optional: true, withdrawal_path: "/settings/privacy"
-```ruby
-Clickwrap.policy :driver_declaration do
- declare :non_professional_driver,
- document: :driver_declaration,
- valid_for: 1.year,
- subject_fingerprint_with: ->(scheme) { scheme.evidence_fingerprint }
+ retain_with :marketing_consent_evidence
end
```
```ruby
-user.clickwraps.declared?(:non_professional_driver, subject: scheme)
-user.clickwraps.declaration(:non_professional_driver, subject: scheme).expires_at
+Clickwrap.withdraw!(:product_updates, actor: current_user, http_request: request,
+ because: "The user withdrew this purpose in privacy settings")
```
-Renewal always starts a new validity period. Correction, supersession, and expiry append linked lifecycle events:
+Withdrawal appends an event — it never deletes or mutates the historical grant. Declarations work the same way: they expire, get corrected, or get superseded through linked lifecycle events, without pretending the original statement never happened.
-```ruby
-Clickwrap.correct_declaration!(
- :non_professional_driver,
- actor: user,
- subject: scheme,
- replaces: old_receipt,
- http_request: request,
- submission: clickwrap_submission
-)
-```
-
-The host retains domain-specific eligibility and declaration models. Clickwrap owns presentation, evidence, lifecycle, receipts, and verification—not your business rules.
-
-## Narrow, one-time authorizations
+Three of those transitions are new statements by the same person rather than administrative flags, so each one is captured through a real presentation and submission, exactly like the first statement was:
```ruby
-Clickwrap.policy :withdrawal_authorization do
- acknowledge :withdrawal_requirements
-
- declare :ride_exclusivity,
- subject_fingerprint_with: ->(withdrawal) { withdrawal.covered_rides_fingerprint }
-
- authorize :withdrawal,
- one_time: true,
- valid_for: 10.minutes,
- requires: %i[withdrawal_requirements ride_exclusivity],
- record_protected_outcome_with: lambda { |withdrawal|
- {
- action: :submitted,
- reference: withdrawal.to_gid.to_s,
- fingerprint: withdrawal.evidence_fingerprint
- }
- }
-end
-```
-
-`capture_and!` locks and consumes the authorization in the same transaction as the withdrawal. Another withdrawal, changed ride set, stale declaration, wrong ordering, or concurrent replay cannot reuse it.
-
-This is the core difference between “the user once accepted something” and “this exact evidence authorized this exact operation.”
-
-## Operator attestations
-
-```ruby
-Clickwrap.policy :manual_bank_transfer do
- attest :beneficiary_matches_verified_identity
- attest :bank_accepted_transfer
- authorize :record_transfer_as_sent, one_time: true
-end
-```
-
-Attestations preserve which authorized operator asserted which operational fact, under which role and authentication context, while the host owns permissions and domain state.
-
-## External agreements and imported receipts
+# The facts someone declared changed. A correction never implies the original
+# was false when it was made.
+Clickwrap.correct_declaration!(:contractor_status, actor: current_user, subject: engagement,
+ submission: clickwrap_submission,
+ because: "The person told us their circumstances changed")
-When Stripe, DocuSign, Ironclad, or another provider owns the presentation, do not pretend your application captured the click:
+# A new validity period, starting now — never the old expiry pushed along, so a
+# stale expiry cannot quietly survive a renewal.
+Clickwrap.renew!(:contractor_status, actor: current_user, subject: engagement,
+ submission: clickwrap_submission,
+ because: "The person renewed their declaration before it lapsed")
-```ruby
-Clickwrap.import_external_receipt!(
- :connected_account_service_agreement,
- actor: user,
- provider_name: "stripe",
- provider_event_id: account.id,
- provider_receipt: account.service_agreement,
- verified_with: :stripe_api,
- verified_at: Time.current
-)
+# Consent that now covers something narrower or wider. Rescoping is not
+# withdrawal: the permission stays active, under new terms.
+Clickwrap.change_consent_scope!(:product_updates, actor: current_user,
+ submission: clickwrap_submission,
+ because: "The person narrowed this permission in privacy settings")
```
-The event is labeled `external_receipt`, preserves provider provenance and validation status, and can participate in host verification without becoming a fictional local presentation.
+Every one of them appends a linked event, leaves the earlier event exactly as it was recorded, and produces a receipt that verifies on its own.
-## Receipts answer “show me exactly what happened”
+Seeds, imports, and admin-created accounts never fake a human click either — `Clickwrap.exempt!` records an explicit exemption with who created it and why, and exemptions never satisfy `agreed_to?`.
+
+## Receipts show exactly what the application recorded
Every event has one canonical JSON receipt and one human-readable HTML projection:
```ruby
receipt = Clickwrap.receipt(event_id)
-
receipt.to_canonical_json
receipt.to_html
-receipt.to_pdf # optional renderer; never the source of truth
receipt.verify
```
-An abbreviated receipt looks like:
+An abbreviated receipt:
```json
{
"schema": "clickwrap.receipt.v1",
"event_id": "01K2Y8T5QY0N4V6N1H4G4CQY8J",
"policy": { "key": "signup", "revision": "sha256:..." },
- "actor": {
- "type": "User",
- "reference": "usr_...",
- "attribution": { "method": "account_registration", "authenticated": false }
- },
"acts": [
{ "statement": "terms", "kind": "agreement", "action": "agreed" },
- {
- "statement": "privacy_notice",
- "kind": "acknowledgment",
- "action": "acknowledged"
- }
+ { "statement": "privacy_notice", "kind": "acknowledgment", "action": "acknowledged" }
],
"documents": [
- { "key": "terms", "version": "2026-08-15", "locale": "en", "sha256": "..." },
{
- "key": "privacy_notice",
+ "key": "terms",
"version": "2026-08-15",
"locale": "en",
- "sha256": "..."
+ "source_digest": "sha256:...",
+ "rendered_digest": "sha256:..."
}
],
"presentation": {
- "manifest_sha256": "...",
+ "manifest_digest": "sha256:...",
"submit_button_text": "Create account",
"offered_at": "2026-08-15T12:34:56.123456Z"
},
- "outcome": { "type": "User", "reference": "usr_...", "status": "created" },
- "request_evidence": {
- "ip_address": { "state": "not_configured" },
- "browser_user_agent": { "state": "not_configured" },
- "ip_geolocation": { "state": "not_configured" }
- },
- "integrity": { "digest_algorithm": "sha256", "verified": true }
+ "integrity": { "digest_algorithm": "sha256", "receipt_digest": "sha256:..." }
}
```
-The bundle can include exact document files, manifest, per-act lifecycle/predecessor graph, protected outcome, optional provider receipts, integrity/checkpoint verification, system explanation, and verifier version.
-
-`to_canonical_json` returns the verifiable core receipt and omits raw sensitive request evidence by default. Raw IP address, browser user-agent, and IP-geolocation values live in a separately encrypted evidence annex with its own digest, authorization, retention, hold, and disposition state. That boundary lets the core event remain immutable when a permitted retention process later removes the annex.
-
-Canonical receipts use versioned schemas and the [JSON Canonicalization Scheme (RFC 8785)](https://www.rfc-editor.org/rfc/rfc8785), plus a published Clickwrap profile for UTC timestamps, decimals, identifiers, binary digests, absent values, and extension names. They never depend on Ruby object serialization, YAML, database column order, or the current policy source. Unknown schema versions fail honestly instead of being “best effort” reinterpreted.
-
-### View and download
-
-With the engine mounted:
-
-```ruby
-clickwrap_receipt_path(receipt)
-```
-
-Actors can view their own receipts. Operator access is always host-authorized:
-
-```ruby
-config.authorize_receipt_access_with = lambda do |controller, receipt|
- controller.current_user == receipt.actor || controller.current_user.admin?
-end
-```
-
-Foreign IDs return not found; existence is not leaked.
-
-### Export only the sensitive fields you intend
-
-```ruby
-Clickwrap.export_receipt(
- receipt,
- requested_by: current_operator,
- because: "Investigate dispute 2026-184",
- include_ip_address: false,
- include_browser_user_agent: false,
- include_ip_geolocation: false
-)
-```
-
-There is intentionally no vague `include_sensitive_context: true` switch. Unredacted operator access and export require host authorization plus a human-readable reason and append an access event. Actor self-service follows the host’s configured disclosure policy without revealing internal fraud/security fields by accident.
-
-### Verify inside or outside the application
-
-```ruby
-Clickwrap::Receipt.verify(canonical_json, documents: document_files)
-```
+Verify it inside the app, or completely outside it with the bundled CLI:
```bash
clickwrap verify receipt.json --documents ./receipt-documents
```
-The standalone verifier does not need the host application’s source code. At the baseline tier it verifies schema, canonical bytes, digests, links, and bundled content consistency; it does not claim that a self-contained file could not have been fabricated by someone controlling every source. Independent anchors/provider signatures add the stronger origin/time evidence they actually supply. Golden fixtures ensure new releases continue verifying every historical receipt format.
-
-## Optional request evidence, private by default
+Golden fixtures make a verifier regression for any released receipt schema fail the test suite.
-Clickwrap always records its event ID, server time, capture channel, policy/application version, configured actor/authentication source, and HTTP request ID when available.
+With the engine mounted, users can view and download their own receipts, and operator access is always host-authorized. Read the [receipts and verification guide](guides/receipts-and-verification.md) for exports, bundles, and what each verification tier does and doesn't establish.
-It records none of these personal/request-derived fields unless the initializer or policy names them:
+## Request evidence is off by default
-- raw IP address;
-- raw browser User-Agent;
-- IP-geolocation country, region, city, postal code, coordinates, timezone, continent, metro code, or accuracy radius;
-- browser/device fingerprints; or
-- actual GPS/device location.
-
-Browser fingerprinting and GPS are never collected by the base gem. IP geolocation is provider-estimated network context—not identity, GPS, a street address, or proof that the person was physically there.
-
-Those defaults are evidence design, not fear of useful data. IP addresses and linked online identifiers can be personal data ([Breyer, C-582/14](https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX%3A62014CJ0582)); keeping them on first-party infrastructure does not remove purpose, lawful-basis, transparency, minimization, protection-by-default, security, retention, or high-risk-assessment duties ([GDPR Articles 5](https://eur-lex.europa.eu/eli/reg/2016/679/art_5/oj/eng), [6](https://eur-lex.europa.eu/eli/reg/2016/679/art_6/oj/eng), [13](https://eur-lex.europa.eu/eli/reg/2016/679/art_13/oj/eng), [25](https://eur-lex.europa.eu/eli/reg/2016/679/art_25/oj/eng), [32](https://eur-lex.europa.eu/eli/reg/2016/679/art_32/oj/eng), and [35](https://eur-lex.europa.eu/eli/reg/2016/679/art_35/oj/eng)). Clickwrap therefore supports rich capture while requiring a present, named posture.
-
-MaxMind expressly describes GeoIP as approximate and not capable of identifying a household, individual, or street address; Cloudflare describes its fields as location information for an IP address ([MaxMind accuracy guidance](https://support.maxmind.com/knowledge-base/articles/maxmind-geolocation-accuracy); [Cloudflare IP geolocation](https://developers.cloudflare.com/network/ip-geolocation/)). Clickwrap preserves that uncertainty instead of polishing an estimate into a stronger claim.
-
-### Enable exactly what one policy needs
+`clickwrap` always records its event ID, server time, capture channel, and policy version. It records **no** IP addresses, browser user-agents, or IP geolocation unless a policy names the field with a purpose and a retention rule:
```ruby
Clickwrap.policy :regulated_authorization do
authorize :regulated_action, one_time: true, valid_for: 10.minutes
- review_request_evidence_configuration_on Date.new(2027, 8, 15)
-
record_ip_address(
encrypted: true,
retain_until: :regulated_evidence_retention_ends,
- because: "Investigate account compromise and disputes about this action",
- legal_basis_reference: "LIA-SECURITY-2026-01"
- )
-
- record_browser_user_agent(
- encrypted: true,
- retain_until: :regulated_evidence_retention_ends,
- because: "Corroborate the client context used for this action",
- legal_basis_reference: "LIA-SECURITY-2026-01"
+ because: "Investigate account compromise and disputes about this action"
)
- record_ip_geolocation(
- country: true,
- region: true,
- city: true,
- postal_code: false,
- latitude_and_longitude: true,
- timezone: true,
- continent: false,
- metro_code: false,
- accuracy_radius_in_kilometers: true,
- using: :trackdown,
- retain_until: :regulated_evidence_retention_ends,
- because: "Corroborate anomalous access and investigate action disputes",
- legal_basis_reference: "LIA-SECURITY-2026-01",
- data_protection_impact_assessment_reference: "DPIA-2026-04"
- )
+ retain_with :regulated_evidence
end
```
-Every enabled IP-geolocation result carries provider name/source, estimated state, resolution time, unavailable reason, and any database/accuracy provenance the resolver supplies. A policy cannot keep provider-derived coordinates while stripping the uncertainty needed to interpret them.
-
-Receipts distinguish `not_configured`, `unavailable`, `recorded`, `redacted_for_this_viewer`, `deleted_after_retention`, and `held`. “Blank” is never allowed to blur “we chose not to collect it” into “collection failed.”
-
-The browser cannot submit or replace server-observed values. Clickwrap conventionally reads `request.remote_ip`, and the host must configure/test trusted proxies correctly; Rails documents the forwarding, trusted-proxy, and spoof-check assumptions in [`ActionDispatch::RemoteIp`](https://api.rubyonrails.org/classes/ActionDispatch/RemoteIp.html).
-
-Required request enrichment resolves before the evidence/domain transaction begins and is carried into it as verified input; it is never filled in later by analytics. A policy chooses explicitly whether an unavailable resolver blocks capture or produces an `unavailable` state. Network resolvers are supported, but local databases or already-verified edge metadata avoid holding a domain transaction open around a remote call.
-
-### Trackdown is the optional official resolver
+Recorded values live in a separately encrypted annex with their own retention, so
+they can be deleted later without rewriting the core event payload. Core payloads
+have their own reviewed disposition path and leave a digest-linked tombstone.
+There is deliberately no `gdpr_compliant_mode` or `maximum_evidence` switch —
+every field is named individually, in plain English.
-```ruby
-bundle add trackdown
-```
+For IP geolocation, [`trackdown`](https://github.com/rameerez/trackdown) 0.4 or newer is the optional official resolver:
```ruby
-config.ip_geolocation_resolver =
- Clickwrap::IpGeolocation::TrackdownResolver.new
-```
-
-`trackdown` remains optional. Clickwrap stores only the fields authorized by the active server policy, never the entire result object. Provider presence is not source trust: Cloudflare-derived fields are marked host-verified only when the application explicitly verifies that requests came through its trusted Cloudflare path.
-
-`footprinted` remains analytics, not authoritative evidence. A sanitized event ID/policy/kind may be emitted to analytics after commit; analytics failure can never undo or substitute for the Clickwrap event.
-
-### Easy installer recipes without a fake compliance switch
-
-The installer can scaffold either starting point:
-
-```bash
-bin/rails generate clickwrap:install \
- --request-evidence-recipe=privacy-minimized
-```
+Trackdown.configure do |trackdown|
+ trackdown.verify_request_came_through_trusted_cloudflare_path_with do |request|
+ request.env["my_app.cloudflare_origin_was_verified"] == true
+ end
+end
-```bash
-bin/rails generate clickwrap:install \
- --request-evidence-recipe=evidence-rich
+config.ip_geolocation_resolver = Clickwrap::IpGeolocation::TrackdownResolver.new
```
-The second recipe asks about every field, purpose, encryption choice, access/export policy, trusted-source posture, and retention rule. It then writes every individual setting into the initializer and disappears. There is no runtime `gdpr_compliant_mode`, `maximum_evidence`, `track_everything`, or `legal_proof` option.
+Clickwrap passes the exact Rack request to Trackdown and records the provider that actually
+answered, its source and database provenance, and Trackdown's per-request trust result. It
+never treats CDN header presence as proof of a trusted path. The host must derive the Rack
+flag above from its real origin protection; Trackdown documents the supported patterns in
+[“Did the request really come through your CDN?”](https://github.com/rameerez/trackdown/blob/v0.4.0/README.md#did-the-request-really-come-through-your-cdn).
-Recipes are scaffolding, never compliance verdicts.
+The [request evidence guide](guides/request-evidence.md) covers every field, the provenance model, and the privacy boundaries.
-## Retention, deletion, and legal holds are first-class
+## Retention, deletion, and legal holds
Every policy chooses an application-defined retention class:
@@ -982,727 +759,576 @@ Clickwrap.retention :ordinary_agreement_evidence do
retain_core_event_for 6.years
delete_recorded_ip_address_after 90.days
delete_recorded_browser_user_agent_after 90.days
- delete_recorded_ip_geolocation_after 90.days
end
```
-Event-based and “later of” rules are supported for regulated records:
+Disposition is previewed, planned, and applied explicitly — and rechecked at apply time, so a newly placed legal hold or changed policy stops a stale plan:
-```ruby
-Clickwrap.retention :regulated_evidence do
- retain_core_event_until :regulated_evidence_retention_ends
- retain_recorded_ip_address_until :security_evidence_retention_ends
- retain_recorded_browser_user_agent_until :security_evidence_retention_ends
- retain_recorded_ip_geolocation_until :security_evidence_retention_ends
-end
+```bash
+bin/rails clickwrap:retention:plan
+bin/rails clickwrap:retention:apply PLAN=01K2Y8T5QY0N4V6N1H4G4CQY8J
```
-```ruby
-config.calculate_retention_time_for :regulated_evidence_retention_ends do |event|
- [
- event.recorded_at_by_server + 5.years,
- event.subject_liquidated_at&.+(3.years)
- ].compact.max
-end
-```
+Destructive methods say exactly what they delete (`Clickwrap.delete_recorded_ip_address!`), deletions append a disposition event, and deleting a user account never silently cascades evidence away. Each event keeps the schedule recorded when that event was created; linked lifecycle events do not inherit their root's elapsed time or get deleted merely because the root became due. Legal holds pause disposition and are recorded through named append/release transitions. Details in the [retention and legal holds guide](guides/retention-and-legal-holds.md).
+
+## Progressive integrity, honestly labeled
-Clickwrap does not decide those periods. It makes reviewed policies executable and auditable.
+Start useful with an ordinary Rails database; add assurance without changing the capture API:
-Preview every disposition before applying it:
+| Tier | What it adds |
+|---|---|
+| Baseline | Canonical receipts, immutable snapshots, SHA-256 digests, standalone verifier |
+| Database hardening | Adapter-specific update/delete protections |
+| Chained history | Per-tenant event chains and checkpoints |
+| Independent anchoring | A verified publication of an exact event-chain snapshot outside the primary database |
+| Third-party timestamps | A provider token over an exact event digest, with the adapter's verification result |
+
+Each tier states exactly what threat it addresses. A local hash is never called tamper-proof, server time is never called trusted time, and an IP address is never called identity. The [integrity guide](guides/integrity.md) has the threat model.
```bash
-bin/rails clickwrap:retention:plan
-bin/rails clickwrap:retention:apply PLAN=01K2Y8T5QY0N4V6N1H4G4CQY8J
+bin/rails clickwrap:verify # verify continuously in production
```
-The plan is immutable, scoped, expiring, and rechecked at apply time. A newly placed hold, changed policy, changed eligibility, or stale plan stops disposition instead of deleting a broader set than the operator reviewed.
+## Works with Devise, Rails authentication, Hotwire, and APIs
-Destructive public methods name exactly what they remove:
+The installer detects your authentication stack and prints the exact door line to add, with your own file path and class name filled in. You add it yourself: this is an explicit adapter you can read in your own controller, not a hidden `after_create` callback the gem installs behind your back.
```ruby
-Clickwrap.delete_recorded_ip_address!(receipt, because: "Retention period ended")
-Clickwrap.delete_recorded_browser_user_agent!(receipt, because: "Retention period ended")
-Clickwrap.delete_recorded_ip_geolocation!(receipt, because: "Retention period ended")
+# Devise
+class Users::RegistrationsController < Devise::RegistrationsController
+ clickwraps_registration_with :signup
+end
```
-Deletion removes the selected encrypted annex value, appends a disposition event, and changes the current receipt projection to `deleted`; it does not rewrite the historical agreement/declaration/authorization. Verification thereafter proves the immutable core event and its disposition history while reporting that the raw annex value is no longer available. A retained digest is described as a retained linkable digest, never automatically called anonymous.
-
-### Legal holds
-
```ruby
-receipt.place_on_legal_hold!(
- because: "Pending dispute 2026-184",
- placed_by: current_operator,
- review_on: 6.months.from_now
-)
+# Rails authentication generator, or any hand-rolled signup door
+def create
+ @user = User.new(user_params)
-receipt.release_legal_hold!(
- because: "Dispute resolved",
- released_by: current_operator
-)
+ unless register_with_clickwrap(:signup, user: @user) { @user.save! }
+ return render :new, status: :unprocessable_entity
+ end
+
+ start_new_session_for @user
+ redirect_to after_authentication_url
+end
```
-A hold pauses scheduled disposition, requires a reason/owner/review date, and is itself append-only evidence.
+Both make account activation and its evidence commit together, with a prospective-actor flow that's honest about the fact that no authenticated user exists yet at render time.
-Deleting an actor account never silently cascades evidence. The installer uses restrictive/nullifying relationships plus a stable configured pseudonymous actor reference. Host retention policy decides what remains.
+The door helpers come in a pair, exactly like `save` and `save!`. The non-bang form absorbs a *refused* signup — a stale presentation, an unticked box, a validation the account failed — into the same human sentences the Devise adapter paints (inline beside the control, once on the record's `:base`) and returns `false`, ready for that 422 re-render. `register_with_clickwrap!` raises instead, for flows that handle the exceptions themselves. An infrastructure failure escapes *both* forms: a broken database is not a refusal to dress up as validation, so the sign-in, the welcome email, and the redirect that would normally follow simply do not happen. That's the difference between a refused signup and a live account nobody can explain.
-### Privacy inventory and actor requests
+During a legacy migration, keep a required dual-write inside that same
+transaction without replacing Devise's controller action:
-Clickwrap can describe what the application configured without pretending that configuration is lawful:
+```ruby
+class Users::RegistrationsController < Devise::RegistrationsController
+ clickwraps_registration_with :signup,
+ after_account_is_saved_inside_transaction: :record_legacy_acceptance!
-```bash
-bin/rails clickwrap:privacy:inventory
-bin/rails clickwrap:privacy:export ACTOR=gid://my-app/User/123
-bin/rails clickwrap:privacy:disposition:plan ACTOR=gid://my-app/User/123
+ private
+
+ def record_legacy_acceptance!(account:, pending_receipt:)
+ account.terms_acceptances.create!(
+ clickwrap_event_id: pending_receipt.event_id,
+ accepted_at: Time.current
+ )
+ end
+end
```
-The inventory lists every policy, personal/request-derived field, stated purpose, host-supplied legal-basis reference, provider/source, encryption state, access callback, retention rule, unresolved host event, and review date. The actor export uses the same authorization/redaction rules as receipts. The disposition command only creates a reviewable plan; it does not decide whether an erasure request overrides retention duties, legal claims, or a hold.
+If that required legacy write fails, the account and Clickwrap evidence roll
+back with it. Remove the hook after parity and cutover are proved.
-Programmatic equivalents return structured results for a host-owned privacy workflow:
+Want the gem's controls but your own button markup? `form.clickwrap_fields` takes a block and hands you the signed presentation, so the wording is read rather than retyped:
-```ruby
-Clickwrap::Privacy.inventory
-Clickwrap::Privacy.export_for(actor, requested_by: current_operator)
-Clickwrap::Privacy.plan_disposition_for(
- actor,
- requested_by: current_operator,
- because: "Verified erasure request DSAR-2026-41"
-)
+```erb
+<%= form.clickwrap_fields :signup, submit_button_text: "Create account" do |clickwrap| %>
+
+<% end %>
```
-Correcting an actor’s current email/name or unlinking an account changes the host projection, not the historical snapshot. A host may append a correction/linkage event when needed; Clickwrap never silently edits what an old receipt recorded.
+`submit:` and `submit_button_text:` are a deliberate pair: `form.clickwrap :signup, submit: "Create account"` binds the words *and renders the button*, while `form.clickwrap_fields :signup, submit_button_text: "Create account"` binds the words and leaves the action to you.
-## Progressive, honest integrity
+Everything is server-rendered HTML: full-page requests, Turbo Drive and Frames, no-JavaScript validation, and Hotwire Native all work with the same helper. JSON/API clients use `Clickwrap.present` to get the server-owned manifest and submit answers with the signed token. Views are ejectable with `bin/rails generate clickwrap:views`, or build fully custom UI on `Clickwrap.present` plus the view helpers — `clickwrap_presentation_token_field`, `clickwrap_statement_check_box`, `clickwrap_statement_radio_button`, and `clickwrap_submit_button` own the envelope name, the control names, and a call to action worded by the signed manifest itself, while you own every class and wrapper around them. The [integrating guide](guides/integrating.md#4-custom-surfaces--the-three-contracts) shows a full custom surface.
-Clickwrap starts useful with an ordinary Rails database and lets serious applications add assurance without changing the capture API.
-
-| Tier | Capability | Honest claim |
-|---|---|---|
-| Baseline | Canonical receipts, immutable snapshots, versioned SHA-256 digests, append-only public API, independent verifier | Detects accidental/ordinary mutation of the verified bytes |
-| Database hardening | Constraints and adapter-specific update/delete protections | Rejects unsupported mutation paths within the documented database threat model |
-| Chained history | Per-tenant or per-aggregate event chains/checkpoints | Makes rewriting history detectable when checkpoints remain trustworthy |
-| Independent anchoring | Heads stored/published outside the primary database | Improves evidence against a privileged primary-database rewrite |
-| Trusted timestamp/provider | RFC 3161 or qualified trust-service receipt adapters | Preserves exactly the assurance and validation status supplied by that provider |
+### Works with `organizations`
-Enable optional hardening explicitly:
+A human `User` can bind an `Organizations::Organization` without collapsing
+the two identities:
-```bash
-bin/rails generate clickwrap:hardening --database
-bin/rails db:migrate
+```ruby
+Clickwrap.policy :organization_terms do
+ agree_to :organization_terms
+ permit_acting_for_organization when_actor_is_at_least: :admin
+ retain_with :ordinary_agreement_evidence
+end
```
-```ruby
-config.digest_canonical_receipts_with = :sha256
-config.chain_event_history_with = :sha256
-config.anchor_event_history_with = MyIndependentAnchor.new
-config.timestamp_receipts_with = MyRfc3161TimestampProvider.new
+```erb
+<%= form.clickwrap :organization_terms,
+ acting_for: current_organization,
+ submit: "Accept for #{current_organization.name}" %>
```
-A local hash is never called tamper-proof. Server-recorded time is never called trusted time. An IP address is never called identity. Provider receipts are never upgraded into guarantees the provider did not make.
+Authority is checked when the form is presented and reread from the membership
+inside the capture transaction. The receipt records the human actor,
+represented organization, actual role at both moments, authority criterion,
+source, and verification times separately. Clickwrap records the configured
+application-authorization fact; it does not decide whether that role is legally
+sufficient. The same integration can create a brand-new organization and its
+owner membership atomically through `create_represented_party_with_clickwrap`.
+See [Binding an organization through a human actor](guides/organizations.md).
-Run verification continuously:
+## Recipes
-```bash
-bin/rails clickwrap:verify
-bin/rails clickwrap:verify EVENT_ID
+Two situations come up in almost every real app. Here's exactly how to handle both.
+
+### "Accept the new Terms to continue" — wall the app until updated Terms are accepted
+
+You know how Apple Developer releases new terms every few months and walls off the entire dashboard until you accept them? Same pattern here: legal ships a new version of your Terms, and nobody uses your app again until they've agreed to it. Accepting the new version supersedes the old one — and you keep a receipt for every version each user ever agreed to, so you always know exactly who agreed to exactly what, and when.
+
+Bump the document version when the new text ships — in the file itself, beside
+the words that changed. The top of `app/content/legal/terms.md`:
+
+```markdown
+---
+title: Terms of Service
+last_updated: 2026-11-01
+---
```
-## Multi-tenancy, actors, subjects, and authority
+It was `2026-08-15`; new words mean a new label. (A trailing `# comment` on
+that line is read as YAML reads it — not part of the label.)
-The conventional actor is `User`, but nothing is hard-coded:
+And require the current version in the policy:
```ruby
-Clickwrap.configure do |config|
- config.actor_class_name = "Account"
- config.current_actor_method_name = :current_account
+# config/clickwrap.rb
+Clickwrap.policy :current_terms do
+ agree_to :terms, require_current_version: true
- config.find_current_tenant_with = lambda do |controller|
- controller.current_organization
- end
+ retain_with :ordinary_agreement_evidence
end
```
-Actors, subjects, and tenants are separate:
+Mount the built-in acceptance screen and wall the app:
```ruby
-Clickwrap.capture!(
- :logo_rights_declaration,
- actor: current_user,
- subject: @organization,
- tenant: current_organization,
- http_request: request,
- submission: clickwrap_submission
-)
+# config/routes.rb
+mount Clickwrap::Engine => "/agreements"
```
-Actor snapshots include only configured fields. Clickwrap never serializes a whole user or domain object into evidence.
-
-Authentication, actor, organization, and subject are not collapsed into one polymorphic ID. A signed-in employee acting for an organization can be represented explicitly:
-
```ruby
-Clickwrap.capture!(
- :organization_terms,
- actor: current_user,
- acting_for: current_organization,
- subject: contract,
- authentication_context: clickwrap_authentication_context,
- http_request: request,
- submission: clickwrap_submission
-)
+class ApplicationController < ActionController::Base
+ # Nobody gets past this until they've accepted the current Terms. Clickwrap's
+ # own acceptance, receipt, withdrawal, and document screens stay reachable
+ # automatically, so this cannot redirect-loop its remediation page.
+ requires_clickwrap :current_terms
+end
```
-By default, the configured actor must match the authenticated principal. Delegation, guardianship, service-account action, and impersonation are rejected unless the policy and host authority adapter explicitly permit them. When permitted, the receipt preserves the authenticated principal, asserted actor, represented party, authority source, role, and verification time as separate facts; Clickwrap does not decide whether that authority is legally sufficient.
+Publish the new version and every signed-in user gets redirected to the acceptance screen on their next request — and sent back to wherever they were going the moment they accept. Preview the blast radius before you activate it:
+
+```bash
+bin/rails clickwrap:reacceptance:plan POLICY=current_terms
+```
-### Anonymous actors
+What you get for free: the new acceptance supersedes the old one (`agreed → superseded`) without rewriting anything, and every receipt pins the exact version, locale, and byte digest of what each user agreed to — so "which exact Terms did this person accept, and when?" stays answerable years later.
-Use a host-owned stable opaque identifier—not an IP address:
+Want to wall off only *parts* of the app instead? Gates are per-controller and per-action, and different areas can require different policies:
```ruby
-actor = Clickwrap.anonymous_actor("checkout_#{signed_checkout_id}")
+class BillingController < ApplicationController
+ requires_clickwrap :current_terms
+end
+
+class Api::DashboardController < ApplicationController
+ requires_clickwrap :developer_terms, only: %i[show update]
+end
```
-The host owns later account linking and identity/capacity decisions.
+### "I agree" before the account even exists — signup, Google sign-in
+
+At signup, people click "I agree" before they have an account with you: there's no `current_user` to hang the acceptance on yet, and the acceptance has to survive account creation. `clickwrap` models this honestly as a *prospective-actor* flow — the acceptance binds to a short-lived signed registration flow, then the account and its acceptance evidence commit in one database transaction, and the receipt records that this was an account registration (not an authenticated session).
-### System-created records and explicit exemptions
+For plain email/password signup, the Devise and Rails-authentication adapters above already do all of this — `form.clickwrap :signup` in your signup form is the whole integration.
-Seeds, imports, administrators, invitations, and service accounts must never “accept” by omitting a browser parameter or by fabricating a human click:
+For Google sign-in (OAuth, One Tap), the click happens on Google's side, so put the acceptance on a "finish creating your account" screen after the callback:
```ruby
-Clickwrap.exempt!(
- :signup,
- actor: Clickwrap.system_actor("database_seed"),
- subject: user,
- because: "Generated demo account; no human signup occurred"
-)
+# The OAuth callback doesn't create the account yet — it stashes what Google
+# said and sends the person to finish signing up.
+def google
+ session[:pending_oauth] = request.env["omniauth.auth"].slice("provider", "uid", "info")
+ redirect_to new_finish_signup_path
+end
+```
+
+```erb
+<%# The finish screen: name and email prefilled from Google, plus your Terms. %>
+<%= form_with model: @user, url: finish_signup_path do |form| %>
+ <%= form.clickwrap :signup, submit: "Create account" %>
+<% end %>
```
-The event is an `exemption`, not an agreement. Policies can permit or reject it explicitly. Every exemption records who/what created it and why.
+```ruby
+def create
+ @user = User.new(user_attributes_from(session[:pending_oauth]))
-Exemptions never satisfy `agreed_to?`, `consented_to?`, or another human-action predicate unless a policy asks the separate `exempted_from?` question. There is no “missing checkbox means system account” inference.
+ # Account + acceptance commit together, or neither happens. A refused
+ # submission re-renders the finish screen with the reason beside the control.
+ unless register_with_clickwrap(:signup, user: @user) { @user.save! }
+ return render :new, status: :unprocessable_entity
+ end
-## Hotwire, Hotwire Native, APIs, and no-JavaScript flows
+ session.delete(:pending_oauth)
+ sign_in @user
+ redirect_to root_path
+end
+```
-The default helper is server-rendered HTML and works with:
+The registration flow lives in your session and the presentation token is valid for two hours by default, so both comfortably survive the round-trip to Google and back. One thing `clickwrap` will not do, on purpose: record an agreement from the OAuth callback alone. "By continuing you agree" with no affirmative act isn't evidence of anything — a real acceptance step has to happen somewhere, and the finish screen is where it belongs.
-- normal full-page requests;
-- Turbo Drive and Turbo Frames;
-- validation re-renders with no JavaScript;
-- Hotwire Native web screens;
-- custom native/API presentations; and
-- operator/admin surfaces.
+### One person accepts for the whole company — organization agreements
-No Stimulus controller is required for correctness. An optional tiny controller may improve disabled-submit affordances, but server validation and evidence capture work without it.
+Your customer is a company — but companies don't click checkboxes, people do. When an admin accepts your business terms "for Acme Inc.", two facts matter and must never blur into each other: the *organization* is the party the terms are for, and a *specific human* performed the acceptance on its behalf. Years later, the question is always the same: exactly which person accepted for the company, and what authority did they have when they did?
-### Hotwire Native
+Declare in the policy who is allowed to accept for an organization — membership alone is deliberately not enough:
-Use the web component whenever possible. Legal-document links can open in the appropriate modal/sheet/external-browser context chosen by the host native shell. The same presentation token and receipt contract applies.
+```ruby
+Clickwrap.policy :organization_terms do
+ agree_to :business_terms
-Native path configuration remains host-owned. Mount/capture routes include both GET and form-action paths so validation stays in the intended navigation context.
+ permit_acting_for_organization when_actor_is_at_least: :admin
-### JSON/API clients
+ retain_with :ordinary_agreement_evidence
+end
+```
-Present a policy through the same server-owned presenter:
+Make the represented company conspicuous in the UI, and pass it as `acting_for:`:
-```ruby
-presentation = Clickwrap.present(
- :signup,
- actor: api_actor,
- locale: :es,
- capture_channel: :native_api,
- submit_button_text: "Crear cuenta"
-)
+```erb
+
You are accepting these terms for <%= current_organization.name %>.
-render json: presentation
+<%= form.clickwrap :organization_terms,
+ acting_for: current_organization,
+ submit: "Accept for #{current_organization.name}" %>
```
-The client renders the declared statements and returns only the signed token plus answers:
+Then capture the acceptance and stamp the organization in one transaction, so the rest of your app can ask a plain domain question:
```ruby
-Clickwrap.capture!(
- :signup,
- actor: api_actor,
- capture_channel: :native_api,
- submission: Clickwrap.submission_from(params),
- client_reported_context: permitted_client_context
-)
+def create
+ organization = current_organization
+
+ capture_clickwrap_and!(:organization_terms, acting_for: organization) do |pending_receipt|
+ organization.update!(terms_accepted_with_clickwrap_event_id: pending_receipt.event_id)
+ end
+
+ redirect_to organization_settings_path
+end
```
-`submission_from` reads only the signed presentation token and the answer keys/types declared by that manifest; unknown keys and malformed choices are rejected. Client-reported values remain explicitly labeled. They can never masquerade as server-observed IP address, server time, trusted identity, or provider-estimated IP geolocation.
+When the form is rendered, `clickwrap` verifies authority and signs that
+presentation-time source, role, criterion, and verification time into the
+manifest. At submit it requires a current membership in that exact
+organization and rereads and locks the membership role *inside* the capture
+transaction. An admin demoted between render and submit is refused; a still-
+authorized role change is recorded honestly as two different snapshots. A
+token rendered for one organization is rejected for another. The receipt keeps
+the human actor, represented organization, both authority checks, and the
+protected outcome as separate facts. An organizational acceptance never
+quietly answers a personal one, and vice versa:
-## Accessible defaults without a fake certification
+```ruby
+user.clickwraps.current_for?(:organization_terms, acting_for: organization) # => true
+user.clickwraps.current_for?(:organization_terms) # => false
+```
-The reference helper and views ship with tested:
+That receipt is exactly what you'll be asked to produce if the agreement is ever disputed: who accepted, for which company, in what role, verified when. Whether that role was *sufficient to bind the company* is a question for your counsel when they choose the `when_actor_is_at_least:` criterion — `clickwrap` records the facts that answer it. Works out of the box with the [`organizations`](https://github.com/rameerez/organizations) gem, or with your own authority model via a registered adapter. The [organizations guide](guides/organizations.md) has the full walkthrough.
-- explicit labels and programmatic names;
-- initially unselected controls;
-- visible keyboard focus;
-- high-contrast conventional links;
-- `aria-invalid` and `aria-describedby` error relationships;
-- error summary and focus behavior;
-- keyboard operation;
-- non-color-only meaning;
-- no-JavaScript validation;
-- locale-aware document selection; and
-- review/correction support for consequential submissions.
+If the organization does not exist until this same form creates it, opt into
+that materially different flow explicitly:
-The whole host page still determines placement, clutter, contrast, action wording, accessibility, and notice quality. Clickwrap can lint known hazards; it cannot certify a host application as accessible or an agreement as enforceable.
+```ruby
+Clickwrap.policy :organization_creation do
+ declare :authority_and_content_rights,
+ statement: "I am authorized to create and act for this organization and may use the content I submit.",
+ document: nil,
+ protected_outcome_version: "created-organization-v1",
+ record_protected_outcome_with: ->(organization) {
+ Clickwrap.protected_outcome(
+ action: :created,
+ record: organization,
+ facts: { name: organization.name }
+ )
+ }
-## Operations you can understand at 03:00
+ permit_acting_for_organization(
+ when_actor_is_at_least: :owner,
+ including_when_this_action_creates_the_organization: true
+ )
-```bash
-bin/rails clickwrap:doctor
-bin/rails clickwrap:publish:plan
-bin/rails clickwrap:publish
-bin/rails clickwrap:reacceptance:plan POLICY=current_terms
-bin/rails clickwrap:verify
-bin/rails clickwrap:export EVENT_ID
-bin/rails clickwrap:retention:plan
-bin/rails clickwrap:retention:apply PLAN=PLAN_ID
-bin/rails clickwrap:holds:review
-bin/rails clickwrap:privacy:inventory
-bin/rails clickwrap:reconcile_external_actions
+ retain_with :ordinary_agreement_evidence
+end
```
-`clickwrap:doctor` reports objective configuration and data facts:
-
-```text
-✓ 6 policies compiled
-✓ all referenced documents are published and digest-verified
-✓ signup has an atomic Devise integration
-✓ every required gate has a remediation route
-✓ request-derived personal data is off by default
-! withdrawal_authorization records IP geolocation city without a review date
-! Cloudflare source trust is unverified
-✓ no overdue disposition jobs
-✓ all checked event digests verify
+```erb
+<%= form_with model: @organization do |form| %>
+ <%= form.clickwrap :organization_creation,
+ acting_for: @organization,
+ submit: "Create organization" %>
+<% end %>
```
-It never prints “compliant,” “court-proof,” or “audit guaranteed.”
+```ruby
+create_represented_party_with_clickwrap(
+ :organization_creation,
+ represented_party: @organization
+) do |pending_receipt|
+ @organization.save!
+ @organization.add_member!(current_user, role: :owner)
+ @organization.update!(creation_clickwrap_event_id: pending_receipt.event_id)
+ @organization
+end
+```
-Metrics and notifications use stable policy/kind/outcome names without raw personal data labels. Sensitive values never appear in ordinary logs, exceptions, `inspect`, notifications, or metrics.
+The form helper creates a server-owned browser-flow binding automatically. The
+manifest says authority is `not_yet_verifiable` because the membership does not
+exist yet; after the protected block returns the persisted organization and
+creates its owner membership, the adapter verifies them and Clickwrap rebinds the final GlobalID
+before commit. If any part fails, none of the organization, membership,
+evidence, or protected outcome commits. The explicit declaration is still what
+records the human's claim of pre-existing real-world authority: an owner role
+created by the transaction proves an application fact, not the truth or legal
+sufficiency of that claim.
-## Testing is a first-class API
+## Testing your integration
-Include the helpers in Minitest:
+Documents must be published in the test database too — presentations refuse unpublished documents in tests exactly as in production:
```ruby
+# test/test_helper.rb
class ActiveSupport::TestCase
include Clickwrap::TestHelpers
+ parallelize_setup { Clickwrap.publish! } # once per parallel worker...
end
+Clickwrap.publish! # ...and once per process
```
-Create real, internally consistent test evidence without knowing table details:
-
```ruby
-receipt = capture_clickwrap(
- :signup,
- actor: user,
- answers: { terms: true, privacy_notice: true }
-)
+receipt = submit_clickwrap(:signup, actor: user, answers: { terms: true, privacy_notice: true })
assert_clickwrap_current :signup, actor: user
assert_clickwrap_agreed_to :terms, actor: user
-assert_clickwrap_acknowledged :privacy_notice, actor: user
assert_clickwrap_receipt_verifies receipt
```
-System-test helpers drive the actual UI:
+`submit_clickwrap` is the test factory: it presents the policy through the real presenter, answers it, and captures — and it *raises* when the capture is refused, because in a test a failed capture is a failed test. That is deliberately a different verb from the controller's `capture_clickwrap`, which captures a submission a person actually sent and absorbs refusals into `false`. Same word for both would mean one name with two opposite answers to "what happens when this is refused".
+
+Integration tests can't fabricate a signed presentation token by hand — that's the point — so they read it off the rendered page the way a browser does:
+
+```ruby
+post user_registration_path, params: {
+ user: { email: "person@example.com", password: "a-real-password" },
+ **clickwrap_params_from(new_user_registration_path) # GET the page, affirm everything
+}
+
+# Decline one statement instead:
+declined = clickwrap_params_from(new_user_registration_path, answers: { terms: false })
+
+# Choice statements submit their real rendered values. By default the helper
+# selects the first offered radio choice; name a different choice explicitly:
+contractor = clickwrap_params_from(
+ new_user_registration_path,
+ answers: { employment_kind: "contractor" }
+)
+```
+
+Checkbox statements default to their affirmative value. Radio statements
+default to the first choice rendered by the application, so tests exercise a
+value the server actually offered instead of a fabricated checkbox value.
+Pass the exact choice key when the choice matters. For a conventional
+`yes`/`no` radio group, `false` selects `no`; explicit choice keys remain the
+clearest option for domain-specific choices.
+
+If one page renders several independent Clickwrap forms, select the exact form;
+the helper refuses an ambiguous page instead of combining one form's token with
+another form's answers:
```ruby
-complete_clickwrap :signup
-click_button "Create account"
+submission = clickwrap_submission_params_from(
+ response,
+ form_css_selector: "form[action='/withdrawals/confirm']"
+)
```
-Fault injection proves required atomicity:
+Fault injection proves the atomicity claim in your own suite:
```ruby
Clickwrap::Testing.fail_next_event_write do
- assert_raises(Clickwrap::EventWriteFailed) do
- perform_signup
- end
+ assert_raises(Clickwrap::EventWriteFailed) { perform_signup }
end
-
assert_not User.exists?(email: "person@example.com")
-assert_no_clickwrap_event :signup
```
-Concurrency, duplicate-submit, stale-token, actor/subject swap, disposition, legal-hold, export round-trip, and legacy-import helpers ship with the gem. No tests make real provider network calls.
+## Configuration
-## The generated initializer explains itself
-
-The complete initializer is annotated in plain English. A representative configuration looks like:
+The generated initializer is fully annotated and every setting reads like a sentence. The essentials:
```ruby
# config/initializers/clickwrap.rb
Clickwrap.configure do |config|
config.actor_class_name = "User"
config.current_actor_method_name = :current_user
- config.parent_controller_class_name = "ApplicationController"
-
- config.find_current_tenant_with = lambda do |controller|
- controller.current_organization if controller.respond_to?(:current_organization)
- end
config.authorize_receipt_access_with = lambda do |controller, receipt|
controller.current_user == receipt.actor
end
- config.authorize_unredacted_request_evidence_access_with =
- lambda do |controller, receipt, because|
- controller.current_user&.security_operator? && because.present?
- end
-
- config.identify_actor_with = ->(actor) { actor.to_gid.to_s }
- # Add only reviewed fields your receipts truly need; never serialize the model.
- config.snapshot_actor_with = ->(_actor) { {} }
- config.describe_authentication_with = lambda do |controller|
- { method: :authenticated_session, authenticated_at: controller.session[:authenticated_at] }
- end
+ # Safe defaults: no IP address, browser user-agent, or IP geolocation is stored.
+ # Enable fields per policy, each with a plain-English purpose and retention rule.
- config.store_document_contents_in = :database
- config.digest_canonical_receipts_with = :sha256
- config.chain_event_history_with = nil
- config.anchor_event_history_with = nil
- config.timestamp_receipts_with = nil
- config.application_version = -> { ENV["RELEASE_SHA"] }
-
- # Safe defaults: no raw network/browser/geolocation data is stored.
- config.record_ip_address_by_default = false
- config.record_browser_user_agent_by_default = false
- config.record_ip_geolocation_country_by_default = false
- config.record_ip_geolocation_region_by_default = false
- config.record_ip_geolocation_city_by_default = false
- config.record_ip_geolocation_postal_code_by_default = false
- config.record_ip_geolocation_latitude_and_longitude_by_default = false
- config.record_ip_geolocation_timezone_by_default = false
- config.record_ip_geolocation_continent_by_default = false
- config.record_ip_geolocation_metro_code_by_default = false
- config.record_ip_geolocation_accuracy_radius_in_kilometers_by_default = false
-
- # If a default above becomes true, fill in the matching plain-English
- # reason and a retention rule below. The policy compiler rejects an
- # enabled default whose purpose or retention is blank.
- config.reason_for_recording_ip_addresses_by_default = nil
- config.reason_for_recording_browser_user_agents_by_default = nil
- config.reason_for_recording_ip_geolocation_by_default = nil
- config.legal_basis_reference_for_recording_ip_addresses_by_default = nil
- config.legal_basis_reference_for_recording_browser_user_agents_by_default = nil
- config.legal_basis_reference_for_recording_ip_geolocation_by_default = nil
- config.review_default_request_evidence_configuration_on = nil
-
- config.encrypt_recorded_ip_addresses = true
- config.encrypt_recorded_browser_user_agents = true
- config.encrypt_recorded_ip_geolocation = true
-
- # Nil means every policy that enables the field must supply its own rule.
- config.delete_recorded_ip_addresses_after = nil
- config.delete_recorded_browser_user_agents_after = nil
- config.delete_recorded_ip_geolocation_after = nil
-
- config.read_ip_address_from_http_request_with =
- ->(http_request) { http_request.remote_ip }
-
- config.read_browser_user_agent_from_http_request_with =
- ->(http_request) { http_request.user_agent }
-
- config.ip_geolocation_resolver = nil
- config.fail_capture_when_ip_geolocation_is_unavailable = false
-
- # Runs only after required evidence and domain state have committed.
- # Hook failures are reported but can never undo the committed action.
+ # Optional hooks run only after evidence and domain state have committed:
config.after_event_is_committed = ->(event) { }
- config.report_after_commit_failure_with = ->(error, event) { Rails.error.report(error) }
end
```
-Every public setting validates its value and reads like a sentence. Class names are resolved lazily for Rails autoloading. Security-critical ambiguity fails at boot instead of becoming a surprising runtime default. A policy-level request-evidence declaration overrides these application defaults, so a high-risk authorization can collect more context without making ordinary signup inherit it.
+Only your decisions are live in that file. Every setting left at the gem's default appears commented with its value, under prose explaining what it does, so a reader can tell at a glance which lines somebody chose. The one deliberate exception is the request-evidence block: each `record_*_by_default` line is written even when it says `false`, because each is an answer to a question the installer asked, and "we decided not to collect this" is worth reading rather than inferring from a file that does not mention it.
-## Generators
+Class names are strings resolved lazily for autoloading, and ambiguity fails at boot instead of becoming a surprising runtime default. Optional external integrations are explicit: anchoring and timestamping are off (`nil`) until an adapter is configured; optional hook procs have working no-op defaults; and geolocation/document integrations run only when their corresponding policy or storage choice asks for them.
-```bash
-bin/rails generate clickwrap:install
-bin/rails generate clickwrap:policy driver_declaration
-bin/rails generate clickwrap:document terms
-bin/rails generate clickwrap:views
-bin/rails generate clickwrap:hardening --database
-bin/rails generate clickwrap:upgrade
-```
-
-The installer:
-
-- detects integer/UUID keys and supported database features;
-- detects Rails authentication and Devise without making either a hard dependency;
-- stops and explains itself when actor/tenant mappings are ambiguous;
-- asks before wiring signup or mounting routes;
-- asks separately about every request-evidence field;
-- writes plain-English purposes and retention placeholders that must be reviewed;
-- never overwrites host files without normal Rails generator conflict handling; and
-- prints a post-install checklist for documents, semantics, privacy, retention, trusted proxies, full-page UI review, and tests.
-
-Upgrade generators create new migrations. Released migrations are never silently edited underneath an application.
-
-## Migrate without inventing history
-
-### From FinePrint
-
-Preview first:
-
-```bash
-bin/rails clickwrap:import:fine_print:plan
-```
-
-Then import:
-
-```bash
-bin/rails clickwrap:import:fine_print
-```
+### The presentation linter
-FinePrint contract versions and signatures become explicit `imported_legacy` events. Fields FinePrint did not record—presentation manifest, IP address, CTA, protected action—remain `unknown` or `not_collected`; Clickwrap never synthesizes them.
-
-### From `accepted_terms_at`
-
-```ruby
-Clickwrap.import_legacy!(
- :terms,
- actor: user,
- occurred_at: user.accepted_terms_at,
- known: {
- document_version: user.terms_version
- },
- unknown: %i[
- exact_document_bytes
- presentation
- assertion
- submit_button_text
- request_evidence
- ],
- because: "Imported from users.accepted_terms_at"
-)
-```
-
-Imports are append-only, provenance-labeled, idempotent, dry-runnable, and report every unknown. Historical weakness remains visible instead of being laundered into modern certainty.
-
-## Extension seams, not dependency soup
-
-The core has small adapter contracts for:
-
-- document storage;
-- actor/tenant resolution;
-- identity/authentication snapshots;
-- IP geolocation;
-- independent checkpoints/anchors;
-- RFC 3161 or trust-service timestamps;
-- external clickwrap/signature providers;
-- object-lock/WORM storage;
-- PDF rendering;
-- authorization;
-- error reporting;
-- notifications; and
-- post-commit analytics/auditing.
-
-Every optional adapter has a no-op default and explicit capability reporting. Installing Clickwrap never pulls in Redis, Sidekiq, Devise, Trackdown, Active Storage, a PDF library, a cloud SDK, or an external service unless the application chooses that integration.
-
-ActiveSupport notifications are available for instrumentation:
+In development and test, every render is scanned for the mistakes a form can make silently — a preselected consent control, a consent sentence carrying two purposes, a document link below the submit button, a missing presentation token. Findings go to the log as warnings and never raise: a lint finding is a thing to look at, not a reason to stop a page from rendering. It is off in production, because a production request has no business scanning its own HTML on the way out.
```ruby
-ActiveSupport::Notifications.subscribe("event_committed.clickwrap") do |event|
- # event payload contains stable IDs and categories, not raw request evidence
-end
+config.lint_presentations = false # or true to run it in another environment
```
-Required writes are never delegated to notifications. Hooks are for observers, not authorization.
-
-## What Clickwrap does, what your application owns, and what the receipt proves
-
-| Area | Clickwrap provides | Your application/counsel owns | Receipt/evidence |
-|---|---|---|---|
-| Documents | immutable versions, bytes/digests, locales, publication | text, translation, fairness, legal approval, materiality | exact stored version and digest |
-| Presentation | tested controls/helper, manifest, token, stale/replay checks | whole-page placement/design, final CTA, accessibility review | server-generated manifest and accepted answers |
-| Actor | configured reference and authentication snapshot | identity proofing, capacity, authority, guardian/organization rules | exactly which configured actor/context was recorded |
-| Agreements | version/current-state mechanics | enforceability, governing law, substantive terms | agreement event and historical version |
-| Privacy notice | acknowledgment mechanics | transparency content and lawful basis for processing | notice version and acknowledgment event |
-| Consent | purposes, grant/withdrawal/renewal lifecycle | whether consent is the correct basis and whether it is freely given | exact grant/withdrawal history |
-| Declarations | statement snapshot, expiry/correction/supersession | truth, eligibility, domain validation | what was declared, when, for which subject |
-| Authorizations | scope, fingerprint, freshness, one-time consumption | domain permission and external-provider consequences | exact evidence-to-outcome binding |
-| Request evidence | explicit capture, provenance, encryption/redaction/disposition | necessity, lawful basis, disclosure, trusted proxy/source, period | selected fields and honest source/state |
-| Integrity | canonical digests, verification, optional chains/adapters | keys, infrastructure, access controls, backups, operational procedures | verification result and bounded assurance tier |
-| Retention | executable rules, holds, dry-run disposition | legally appropriate periods and case-specific holds | retention/hold/disposition history |
+`nil` (the default) means "decide from the environment".
-Clickwrap is engineering infrastructure, not legal advice or a compliance certificate.
+## Operations
-## What Clickwrap deliberately does not become
-
-Clickwrap does not:
-
-- draft or approve your legal documents;
-- choose a GDPR lawful basis or special-category condition;
-- decide whether a document change is material;
-- guarantee enforceability, admissibility, accessibility, or audit acceptance;
-- verify identity, age, capacity, guardianship, or organizational authority;
-- provide KYC, sanctions screening, fraud scoring, or biometrics;
-- become a cookie CMP, tracker scanner, or script blocker;
-- become DocuSign, Ironclad, a notary, a qualified trust-service provider, or a contract lifecycle platform;
-- call a local hash tamper-proof;
-- call an IP address identity or IP geolocation physical location;
-- require forced scrolling or claim it proves reading;
-- require a sprawling admin/document-authoring suite; or
-- hide collection behind `compliant: true` or `maximum_evidence: true`.
-
-Adapters let those systems contribute provider receipts without changing what Clickwrap itself claims.
-
-## FinePrint and Clickwrap solve different-sized problems
-
-[FinePrint](https://github.com/openstax/fine_print/blob/3b75fbcbcfb048ecd2f4ee7c4f0b9bd3d10f7603/README.md#L7-L25) is established Rails prior art for versioned contracts, signatures, gates, and views. Clickwrap should never market itself as the first Rails agreement gem.
-
-FinePrint’s documented core and [signature model at the audited commit](https://github.com/openstax/fine_print/blob/3b75fbcbcfb048ecd2f4ee7c4f0b9bd3d10f7603/app/models/fine_print/signature.rb#L1-L33) answer:
-
-```text
-Did user U sign version N of contract X?
-```
-
-Clickwrap is for applications that also need to answer:
-
-```text
-Which exact content and presentation was offered?
-Which explicit statements and choices were made?
-Did the required evidence and protected outcome commit together?
-What subject or transaction did it cover?
-Was it withdrawn, corrected, superseded, expired, or consumed?
-Can the complete receipt be reproduced and verified independently?
-Can optional personal request evidence be disposed of honestly?
+```bash
+bin/rails clickwrap:doctor # objective health report, never prints "compliant"
+bin/rails clickwrap:publish # freeze document snapshots (idempotent; also rides db:prepare)
+bin/rails clickwrap:verify # verify event digests
+bin/rails clickwrap:retention:plan # preview disposition
+bin/rails clickwrap:privacy:inventory # every configured personal-data field, purpose, and rule
+bin/rails clickwrap:import:fine_print # migrate from FinePrint without inventing history
```
-The goal is to be easier in the first five minutes and dramatically stronger after five years in production—not FinePrint with more columns.
-
-## Legal and evidentiary posture
-
-Electronic form does not cure an invalid underlying transaction, missing capacity/authority, or a special formality. The US E-SIGN Act preserves electronic validity while retaining substantive requirements and exclusions ([15 U.S.C. § 7001](https://www.law.cornell.edu/uscode/text/15/7001); [15 U.S.C. § 7003](https://www.law.cornell.edu/uscode/text/15/7003)). Electronic form also does not make an unfair term fair ([Directive 93/13/EEC](https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=celex%3A31993L0013)). EU eIDAS distinguishes ordinary electronic evidence from qualified electronic signatures and their specific legal effect ([Regulation (EU) No 910/2014, Article 25](https://eur-lex.europa.eu/eli/reg/2014/910/2024-05-20/eng)).
-
-US appellate formation decisions evaluate conspicuous notice and unambiguous assent in the context of the whole interface; no checkbox color or placement is a universal safe harbor ([Berman v. Freedom Financial Network](https://cdn.ca9.uscourts.gov/datastore/opinions/2022/04/05/20-16900.pdf); [Tejon v. Zeus Networks](https://media.ca11.uscourts.gov/opinions/pub/files/202411114.pdf); [Toth v. Everly Well](https://www.ca1.uscourts.gov/sites/ca1/files/opnfiles/23-1727P-01A.pdf)).
-
-GDPR consent must be demonstrable, distinguishable, and withdrawable, but consent is only one possible lawful basis. A privacy-information acknowledgment is not blanket consent ([GDPR Article 6](https://eur-lex.europa.eu/eli/reg/2016/679/art_6/oj/eng); [GDPR Article 7](https://eur-lex.europa.eu/eli/reg/2016/679/art_7/oj/eng); [AEPD FAQ 02.48](https://www.aepd.es/preguntas-frecuentes/2-tus-obligaciones-como-responsable-del-tratamiento/6-el-deber-de-informacion/FAQ-0248-sobre-si-el-usuario-tiene-que-dar-consentimiento-a-clausula-de-privacidad)). GDPR also requires purpose limitation, data minimization, storage limitation, transparency, and security; “collect everything forever” is not the evidence-maximizing default ([Article 5](https://eur-lex.europa.eu/eli/reg/2016/679/art_5/oj/eng); [Article 13](https://eur-lex.europa.eu/eli/reg/2016/679/art_13/oj/eng); [Article 32](https://eur-lex.europa.eu/eli/reg/2016/679/art_32/oj/eng)).
-
-These sources motivate Clickwrap’s design. They do not turn the gem into legal advice or a universal safe harbor.
+Migrating from FinePrint or a bare `accepted_terms_at` column? Clickwrap's importer appends provenance-labeled events through its supported API: fields the old system never recorded stay `unknown` instead of being laundered into modern certainty. Direct database privileges remain outside that API's boundary. See the [migration guide](guides/migrating.md).
-## Security model
+## Will this hold up in court?
-Clickwrap treats these as hostile until verified:
+Here's the honest version, in plain words, because you deserve better than marketing copy on this question.
-- policy/document/version/validity values submitted by the client;
-- stale or replayed presentation tokens;
-- swapped actor, tenant, subject, or transaction IDs;
-- forwarded IP and Cloudflare headers outside a verified proxy path;
-- client timestamps and client-reported identity/location;
-- duplicate/concurrent submits;
-- mutable document sources;
-- after-commit analytics and provider callbacks; and
-- imported evidence without provider provenance.
+Electronic form alone is not a reason to deny a contract legal effect under the US E-SIGN Act
+([15 U.S.C. § 7001](https://www.law.cornell.edu/uscode/text/15/7001)), and the EU's eIDAS
+regulation says an electronic signature may not be denied legal effect or admissibility solely
+because it is electronic or not qualified ([Regulation 910/2014, Article 25](https://eur-lex.europa.eu/eli/reg/2014/910/2024-05-20/eng)).
+That does not decide what happens around the control in a particular downstream application:
-Security-sensitive values are server-owned, signed/bound, rechecked inside the transaction, and represented by stable failure results. Rails’ CSRF/session/authentication protections remain host responsibilities. Encryption keys, signing keys, and adapter credentials use Rails credentials or application-provided key providers and support rotation with versioned key identifiers.
+**Courts read your whole page, not your checkbox.** In *Berman v. Freedom Financial Network* (a 2022 Ninth Circuit decision, [opinion](https://cdn.ca9.uscourts.gov/datastore/opinions/2022/04/05/20-16900.pdf)), the terms lost: the notice was in tiny gray font, the links to the terms didn't look like links, and the button said "Continue" without mentioning them — even though an acceptance flow existed. Other federal appeals courts run the same whole-interface analysis (*[Tejon v. Zeus Networks](https://media.ca11.uscourts.gov/opinions/pub/files/202411114.pdf)*, *[Toth v. Everly Well](https://www.ca1.uscourts.gov/sites/ca1/files/opnfiles/23-1727P-01A.pdf)*). Placement, font size, contrast, clutter, the words on the button: all decided by *your* page. `clickwrap` renders one accessible, initially-unselected component and records exactly what that component said — it cannot see, or fix, the rest of your screen.
-Report vulnerabilities privately according to `SECURITY.md`. Do not open a public issue containing an exploit or real evidence/PII.
+**The words in your documents matter more than the click.** In the EU, an unfair term in a consumer contract doesn't bind the consumer even when the assent flow was otherwise effective ([Directive 93/13/EEC](https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=celex%3A31993L0013)). A strong record of acceptance does not change the underlying term. The gem records your words; it can't make them fair.
-## Compatibility
-
-The ideal supported matrix is:
-
-- Ruby 3.2 through current Ruby, tested explicitly;
-- Rails 7.1 through current Rails 8.x;
-- PostgreSQL, SQLite, and MySQL for all documented portable core behavior;
-- adapter-specific hardening clearly marked and tested;
-- Rails authentication and Devise, both optional integrations;
-- Turbo/Hotwire and ordinary HTML;
-- integer and UUID primary keys;
-- multi-database applications when evidence and protected action share the documented transaction boundary; and
-- API-only applications for model/service/JSON receipt APIs, with HTML engine mounting optional.
-
-The gem depends only on the Rails components its approved surface needs. It does not depend on the `rails` meta-gem, Redis, a job backend, a JavaScript runtime, an external provider, or a CSS framework.
+**Who acted, and in which capacity.** `clickwrap` records the actor and the authentication and
+authority facts your application supplies; it does not establish identity, capacity, or legal
+authority. For an organization, it keeps the human actor distinct from the represented party and
+records the role or permission criterion your application checked—[see the recipe](#one-person-accepts-for-the-whole-company--organization-agreements).
-The actual released gemspec and CI matrix—not this wishlist—are authoritative once implementation exists.
+**Your jurisdiction and your document type.** The US E-SIGN Act expressly excludes categories
+including wills, specified family-law matters, and specified notices
+([15 U.S.C. § 7003](https://www.law.cornell.edu/uscode/text/15/7003)). In the EU, a
+*qualified* electronic signature has the equivalent legal effect of a handwritten signature;
+Article 25 separately says other electronic signatures may not be denied legal effect or
+admissibility solely because they are electronic or not qualified
+([eIDAS Article 25](https://eur-lex.europa.eu/eli/reg/2014/910/2024-05-20/eng)).
+This gem does not produce or claim a qualified electronic signature.
-## Stability and upgrade promise
-
-Clickwrap follows semantic versioning for its documented Ruby/Rails APIs, but persisted evidence gets a stricter promise:
+**And GDPR consent is its own animal.** Consent has to be demonstrable and withdrawable ([GDPR Article 7](https://eur-lex.europa.eu/eli/reg/2016/679/art_7/oj/eng)) — `clickwrap` gives you both mechanics — but merely acknowledging a privacy notice is not consent (regulator guidance from Spain's AEPD, [FAQ 02.48](https://www.aepd.es/preguntas-frecuentes/2-tus-obligaciones-como-responsable-del-tratamiento/6-el-deber-de-informacion/FAQ-0248-sobre-si-el-usuario-tiene-que-dar-consentimiento-a-clausula-de-privacidad)). That's why `acknowledge` and `consent_to` are different verbs here, with different lifecycles.
-- every released receipt schema, canonicalization profile, digest field, event action, and lifecycle meaning has a permanent golden fixture;
-- new gem versions continue verifying old receipts even when they stop creating that old schema;
-- a format change gets a new explicit schema/version and verifier, never a silent reinterpretation;
-- upgrade generators add migrations and report their exact effects; released migration files are never edited under an installed application;
-- destructive or lossy data transitions require a plan, explicit operator action, and rollback/export guidance;
-- deprecations name the replacement and remain executable for a documented window; and
-- security fixes distinguish a vulnerable capture path from a verifier/display-only issue so operators know what historical evidence, if any, needs review.
+Notice what's left after all of that: **evidence**. Those opinions examine what interface the
+application offered and what action it recorded—not whether checkboxes are valid in the abstract.
+Which exact version of the terms did the server bind to the form? What did the presentation
+manifest say beside the control? Was the control initially unselected? Which explicit submission
+did the server accept? Was consent later withdrawn? Most apps genuinely cannot reconstruct those
+application-side facts; `clickwrap` exists so you can, with a receipt verifiable without the
+producing application's source code. It still does not prove that a person perceived or
+understood the interface.
-The project publishes the CI matrix, generator diffs, benchmark script, receipt golden fixtures, threat-model changes, and upgrade notes with every release. “It still boots” is not enough for a gem whose value is long-lived evidence.
+That's also why nothing in this gem prints "legally binding" or "court-proof": those are conclusions a court reaches about *your* agreement, under *your* jurisdiction's law, looking at *your* whole page and *your* terms. The gem's job is narrower and more useful — making sure that when that day comes, your lawyer is holding the receipt.
-## Performance
+## What `clickwrap` is *not*
-The ordinary capture path is one bounded database transaction with no network call. Documents and compiled policies are cached by immutable digest. Request geolocation, timestamp providers, external anchors, PDFs, and analytics are optional and never hidden in the simple path.
+This gem provides evidence mechanics — excellent ones — and nothing else. It does not:
-There is no global event-history mutex. Sequence/chain scope is per tenant or aggregate, benchmarked under contention, and independently checkpointed where enabled. Bulk export streams records and verifies incrementally.
+- draft or approve legal documents, or decide whether a change is "material";
+- claim compliance, enforceability, admissibility, or "court-proof" anything;
+- verify identity or age, or decide whether a configured role or permission is
+ legally sufficient to bind an organization (identity, KYC, and legal capacity
+ belong elsewhere);
+- become DocuSign, a notary, a cookie CMP, or a contract-lifecycle platform;
+- call a local hash tamper-proof, server time trusted time, or an IP address a person;
+- hide data collection behind a `compliant: true` switch.
-Performance claims are published only with reproducible benchmarks against supported databases.
+Your application and its counsel own the legal text, lawful basis, retention periods, and jurisdiction-specific requirements. `clickwrap` makes configured decisions executable and traceable in evidence—it doesn't make them for you.
## FAQ
### Is this an electronic-signature gem?
-It captures electronic evidence of explicit actions and can import/provider-bind signature receipts. It does not call ordinary clickwrap a qualified electronic signature, notarization, or trusted identity proof.
-
-### Does a user have to open or scroll through the document?
-
-Not by universal default. Clickwrap makes the document available before action and records the exact presentation. A policy can require an accurately observed open/review interaction when the host has a real requirement, but Clickwrap never equates scrolling with reading or understanding.
+It captures electronic evidence of explicit actions and can import provider signature receipts. It does not call a checkbox a qualified electronic signature.
-### Should I record IP addresses and geolocation?
+### Does the user have to scroll through the document?
-Only for policies with a present, documented purpose and reviewed access/retention posture. They can corroborate request context but do not repair weak notice or prove identity/physical location. All such fields default off.
+No — and `clickwrap` never equates scrolling with reading. It makes documents available before action and records the exact presentation. A policy can require an observed open/review interaction if your app truly needs one.
-### Can I use Clickwrap without Devise?
+### Should I record IP addresses?
-Yes. Devise and Rails authentication are convenience adapters over the same public capture APIs.
+Only for policies with a real, documented purpose. They corroborate request context; they don't prove identity or location. Everything defaults off.
-### Can one policy contain several documents and statements?
+### Can I keep my domain models?
-Yes. The receipt preserves each document/version, statement, choice, and ordering independently. Agreement, acknowledgment, and optional consent controls remain semantically separate even when one page presents them together.
+Yes, and you should. `clickwrap` owns presentation, evidence, lifecycle, and receipts — not your payout, eligibility, or employment rules.
-### Can I keep my domain-specific declaration or authorization model?
-
-Yes—and usually should. Clickwrap complements domain models; it does not replace your payout, certification, identity, employment, or eligibility rules.
-
-### Can Clickwrap prove the user saw the page?
-
-It can prove the server generated and accepted a bound presentation manifest and record accurately observed interactions. It cannot prove human attention, comprehension, exact pixels, or legal sufficiency from a database row.
+### Is this GDPR compliant?
-### What happens if Clickwrap is temporarily unavailable?
+No gem can answer that. `clickwrap` gives you privacy-aware mechanisms, truthful defaults, and an inventory of exactly what you configured. Lawful basis, necessity, and data-subject rights remain yours.
-Required evidence fails closed: the same-database protected action rolls back. Optional after-commit hooks fail independently and are reported. Applications can define deliberate emergency/system exemptions with explicit actor and reason; there is no silent rescue-and-continue path.
+## Compatibility
-### Can I delete evidence?
+- Ruby 3.2+, Rails 7.1 through 8.x
+- PostgreSQL, SQLite, and MySQL for all portable core behavior (hardening is adapter-specific and labeled)
+- Integer and UUID primary keys; Devise and Rails authentication both optional
+- Runtime dependencies are only the Rails components the gem actually uses (`activerecord`, `actionpack`, `actionview`, `activesupport`, `railties`) — never Redis, a job backend, a JS runtime, or an external service
-Yes, according to explicit retention/disposition policy and legal holds. Optional request evidence is separately disposable. Core historical evidence is never silently deleted through an actor association, and disposition is itself recorded.
+Persisted evidence gets a stricter promise than semver: every released receipt schema has a permanent golden fixture, new versions keep verifying old receipts, and released migrations are never edited underneath your app — see [Stability and upgrade promise](#stability-and-upgrade-promise).
-### Is this GDPR compliant?
+## Stability and upgrade promise
-No gem can answer that universally. Clickwrap provides privacy-aware mechanisms and truthful defaults. The host remains responsible for lawful basis, necessity, transparency, data-subject rights, security, retention, processors/transfers, DPIAs, and jurisdiction-specific requirements.
+`clickwrap` follows semantic versioning for its Ruby APIs. Evidence formats are stricter: a format change gets a new explicit schema and verifier, never a silent reinterpretation; upgrade generators add migrations and report their effects; and deprecations name their replacement and remain executable for a documented window.
## Development
```bash
bin/setup
-bin/test
-bin/rubocop
-bin/rails test
+bundle exec rake test
+bundle exec rubocop
```
-The project uses Minitest, a dummy Rails application, SimpleCov, RuboCop, Appraisal matrices, SQLite/PostgreSQL/MySQL integration lanes, concurrency/fault tests, generator tests, Brakeman where relevant, and independent receipt-verifier golden fixtures.
-
-Every change to canonicalization, schema, receipts, migrations, cryptographic fields, or lifecycle behavior must prove backward verification against all released fixtures.
+The project uses Minitest with a dummy Rails app, SimpleCov, RuboCop, Appraisal matrices, and SQLite/PostgreSQL/MySQL CI lanes. Fault-injection, concurrency, replay, stale-token, disposition, and golden-receipt tests are load-bearing, not extras.
## Contributing
-Bug reports and focused pull requests are welcome once the repository opens for implementation. Changes to public vocabulary or evidence claims require corresponding documentation, source review, migration/compatibility analysis, and proof-integration coverage.
+Bug reports and focused pull requests are welcome at https://github.com/rameerez/clickwrap. Please run `bundle exec rake test` and `bundle exec rubocop` first.
-Please do not use issues to request jurisdiction-specific legal advice or ask maintainers to approve legal text.
+Two kinds of change need extra care: anything touching public vocabulary or an evidence claim (docs change alongside code, plus a note on receipts already written), and anything touching canonicalization, receipt schemas, digests, or migrations — released evidence formats are permanent. Security reports go through [`SECURITY.md`](SECURITY.md), privately.
## License
-MIT.
+The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
diff --git a/Rakefile b/Rakefile
new file mode 100644
index 0000000..49a13df
--- /dev/null
+++ b/Rakefile
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+begin
+ require "bundler/setup"
+rescue LoadError
+ puts "You must `gem install bundler` and `bundle install` to run rake tasks"
+end
+
+require "bundler/gem_tasks"
+
+require "rdoc/task"
+
+RDoc::Task.new(:rdoc) do |rdoc|
+ rdoc.rdoc_dir = "rdoc"
+ rdoc.title = "Clickwrap"
+ rdoc.options << "--line-numbers"
+ rdoc.rdoc_files.include("README.md")
+ rdoc.rdoc_files.include("lib/**/*.rb")
+end
+
+APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
+load "rails/tasks/engine.rake"
+
+require "rake/testtask"
+
+Rake::TestTask.new(:test) do |t|
+ t.libs << "test"
+ t.pattern = "test/**/*_test.rb"
+ t.verbose = false
+end
+
+task default: :test
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..96a3c4d
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,33 @@
+# Security Policy
+
+`clickwrap` stores legal-evidence records: frozen document snapshots, signed presentation tokens, append-oriented evidence events with fixed named disposition transitions, canonical receipts, and — only when a policy explicitly asks for them — encrypted IP addresses, browser user-agent strings, and IP-geolocation estimates. Please report suspected vulnerabilities privately, and do not include real evidence records, receipts, presentation tokens, IP addresses, or any other personal data in a report. Redact or synthesize a reproduction instead; a minimal fabricated policy and document reproduce almost every issue just as well.
+
+## Supported versions
+
+Security fixes are released for the latest published version. The maintained test matrix covers Ruby 3.2, 3.3, 3.4, and 4.0 with patched Rails 7.1, 7.2, 8.0, and 8.1 releases. Older Ruby and Rails versions may remain installable for compatibility, but runtimes that no longer receive upstream security fixes are not security-supported.
+
+## Reporting a vulnerability
+
+Use GitHub's **Report a vulnerability** button on the [`clickwrap` security advisories page](https://github.com/rameerez/clickwrap/security/advisories) so the report and any proposed fix remain private. If GitHub's private reporting flow is unavailable, email `rubygems@rameerez.com` with the subject `clickwrap security report`.
+
+Include:
+
+- the affected version and environment;
+- a minimal reproduction or proof of concept, using synthetic documents, policies, and actors;
+- the impact you believe is possible — in particular, whether it affects the capture path (evidence that could be recorded wrongly) or only the verifier/display path (evidence that could be read or rendered wrongly); and
+- any suggested mitigation or patch.
+
+Do not open a public issue for an undisclosed vulnerability. We will acknowledge the report, investigate it, and coordinate disclosure and credit with you. If the issue affects downstream applications, we will prioritize a patched release and clear upgrade guidance, and say plainly whether historical evidence needs review.
+
+## Operational security
+
+`clickwrap` provides evidence mechanics. Applications remain responsible for:
+
+- Rails credentials, the `secret_key_base` that signs presentation tokens, and the `ActiveRecord::Encryption` keys that protect the request-evidence annex — including key rotation and a documented recovery path, since evidence outlives the deployment that wrote it;
+- trusted-proxy configuration (`config.hosts`, `config.action_dispatch.trusted_proxies`) so that `request.remote_ip` reflects the connection the application actually observed rather than a client-supplied header;
+- CSRF protection, session cookie configuration, authentication, and the authorization of every controller action that renders a policy or submits evidence;
+- database, backup, replica, log, and observability access controls, and keeping presentation tokens and request evidence out of application and proxy logs;
+- deciding who may read unredacted request evidence, wiring `config.authorize_receipt_access_with` and `config.authorize_unredacted_request_evidence_access_with` accordingly, and auditing that access; and
+- retention periods, legal holds, and disposition decisions — the gem executes the retention classes you write and refuses to dispose of held evidence, but the periods, the lawful basis for keeping or deleting a record, and the decision to place or release a hold are yours.
+
+The gem does not, and does not claim to, provide compliance with any law, enforceability of any agreement, tamper-proof storage, trusted or attested time, or verified identity. A receipt is evidence of what your application recorded and can be verified against its own canonical bytes; it is not proof that the recorded facts are true, that a person read or understood a document, or that the record could not have been fabricated by someone who controls every source. Independent anchors and provider signatures add only the origin and time evidence they actually supply.
diff --git a/app/assets/stylesheets/clickwrap.css b/app/assets/stylesheets/clickwrap.css
new file mode 100644
index 0000000..f71bf6d
--- /dev/null
+++ b/app/assets/stylesheets/clickwrap.css
@@ -0,0 +1,241 @@
+/*
+ * clickwrap — bundled default styles.
+ *
+ * Deliberately small and unopinionated. It sets the things that change whether
+ * a person can USE the block — focus you can see, links that look like links,
+ * targets big enough to press, errors that read as errors in words — and stops
+ * well short of a design. Everything is scoped under `.clickwrap`, so dropping
+ * this file into a host stylesheet cannot repaint anything else on the page.
+ *
+ * Self-contained on purpose: engine views can't lean on the host's CSS
+ * framework (a Tailwind host never scans gem view files, so utility classes
+ * used here would simply not exist in its build). Semantic clickwrap-* classes
+ * plus plain modern CSS work in any host with zero build integration.
+ *
+ * Theme it with CSS variables from any host stylesheet:
+ *
+ * :root {
+ * --clickwrap-link: #1d4ed8;
+ * --clickwrap-error: #b3261e;
+ * }
+ *
+ * Want something completely different? `bin/rails generate clickwrap:views`
+ * ejects the templates into your app; restyle them and skip this file.
+ *
+ * What this file cannot do is make a page accessible. Placement, contrast
+ * against YOUR background, surrounding clutter, reading order, and the words
+ * themselves belong to the host page. These are defaults that try not to be the
+ * problem.
+ */
+
+.clickwrap {
+ --clickwrap-text: #111827;
+ --clickwrap-muted: #4b5563;
+ --clickwrap-link: #1d4ed8;
+ --clickwrap-link-visited: #6b21a8;
+ --clickwrap-error: #b3261e;
+ --clickwrap-error-surface: #fdf2f2;
+ --clickwrap-border: #6b7280;
+ --clickwrap-focus: #111827;
+ --clickwrap-radius: 0.375rem;
+
+ color: var(--clickwrap-text);
+ font: inherit;
+ line-height: 1.5;
+}
+
+/* --- Focus ------------------------------------------------------------------
+ * A visible focus ring on everything focusable, in both directions: the outline
+ * is drawn, and it is offset so it stays visible on top of a control that has
+ * its own border. Never `outline: none`.
+ */
+
+.clickwrap :is(a, button, input, summary, [tabindex]):focus-visible {
+ outline: 3px solid var(--clickwrap-focus);
+ outline-offset: 2px;
+ border-radius: 2px;
+}
+
+/* --- Screen-reader-only text -------------------------------------------------
+ * The "(opens in a new tab)" truth without the clutter: announced, never drawn.
+ * Not `display: none` and not `visibility: hidden` — both remove it from the
+ * accessibility tree, which is the one place it needs to exist.
+ */
+
+.clickwrap-sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip-path: inset(50%);
+ white-space: nowrap;
+ border: 0;
+}
+
+/* --- Statements --------------------------------------------------------------
+ * The default is one line: a checkbox and a sentence with links in it. Aligned
+ * on the first line's baseline rather than the box's top edge, so the control
+ * sits with the words instead of above them, and nothing wraps to a second row
+ * that a person has to read as a second thing.
+ */
+
+.clickwrap-statements { display: flex; flex-direction: column; gap: 1rem; }
+
+.clickwrap-statement__control { display: flex; align-items: flex-start; gap: 0.6rem; }
+
+.clickwrap-statement__checkbox,
+.clickwrap-statement__radio {
+ /* Sized in em so the box matches the sentence it belongs to instead of
+ towering over small text, and optically centered against the FIRST line
+ of the label: (line-height minus box) halved. Flex `baseline` cannot do
+ this — a checkbox aligns its bottom edge to the baseline and ends up
+ floating high. The rem fallback covers engines without the lh unit. */
+ width: 1em;
+ height: 1em;
+ margin: 0.2em 0 0;
+ margin-top: calc((1lh - 1em) / 2);
+ flex: 0 0 auto;
+ accent-color: var(--clickwrap-focus);
+}
+
+.clickwrap-statement__label,
+.clickwrap-statement__choice-label { cursor: pointer; }
+
+.clickwrap-statement__choices {
+ border: 1px solid var(--clickwrap-border);
+ border-radius: var(--clickwrap-radius);
+ padding: 0.75rem 1rem;
+ margin: 0;
+}
+
+.clickwrap-statement__legend { font-weight: 600; padding: 0 0.25rem; }
+.clickwrap-statement__choice { display: inline-flex; align-items: center; gap: 0.4rem; margin-right: 1rem; }
+
+/* --- Documents ---------------------------------------------------------------
+ * Links look like links: underlined, in a conventional link color, and distinct
+ * from the surrounding text by more than color alone. In the composed line they
+ * sit inside the sentence; on an itemized statement they sit under it.
+ */
+
+.clickwrap-documents { list-style: none; margin: 0.4rem 0 0; padding: 0 0 0 1.75rem; }
+.clickwrap-documents__item { margin: 0.15rem 0; font-size: 0.9375rem; }
+
+.clickwrap-documents__link,
+.clickwrap-link {
+ /* The links read AS PART of the sentence: same color as the words around
+ them, distinguished by a subtle underline — still a non-color signal, so
+ the distinction survives color-blindness and forced-colors modes. The
+ underline firms up on hover/focus as the affordance. */
+ color: inherit;
+ text-decoration: underline;
+ text-decoration-thickness: 1px;
+ text-decoration-color: color-mix(in srgb, currentColor 45%, transparent);
+ text-underline-offset: 0.18em;
+}
+
+.clickwrap-documents__link:hover,
+.clickwrap-documents__link:focus-visible,
+.clickwrap-link:hover,
+.clickwrap-link:focus-visible { text-decoration-color: currentColor; }
+
+.clickwrap-documents__link:visited,
+.clickwrap-link:visited { color: inherit; }
+
+/* Versions belong on a receipt, where somebody is reading the record. Beside a
+ checkbox they are noise nobody can act on. */
+.clickwrap-documents__version { color: var(--clickwrap-muted); font-size: 0.8125rem; }
+
+/* --- Errors ------------------------------------------------------------------
+ * Never color alone. The message is words, it is prefixed by the word "Error",
+ * and the block carries a heavy left rule — so it still reads as an error in
+ * grayscale, in high-contrast mode, and to someone who cannot see red.
+ */
+
+.clickwrap-error-summary {
+ border: 2px solid var(--clickwrap-error);
+ border-left-width: 8px;
+ background: var(--clickwrap-error-surface);
+ border-radius: var(--clickwrap-radius);
+ padding: 0.85rem 1rem;
+ margin: 0 0 1rem;
+}
+
+.clickwrap-error-summary__heading { margin: 0 0 0.5rem; font-size: 1.0625rem; font-weight: 700; }
+.clickwrap-error-summary__list { margin: 0; padding-left: 1.25rem; }
+.clickwrap-error-summary__link { color: var(--clickwrap-error); text-decoration: underline; }
+
+.clickwrap-statement--invalid {
+ border-left: 4px solid var(--clickwrap-error);
+ padding-left: 0.75rem;
+}
+
+.clickwrap-statement__error { color: var(--clickwrap-error); margin: 0.35rem 0 0; font-size: 0.9375rem; }
+.clickwrap-statement__error-prefix { font-weight: 700; }
+
+.clickwrap-flash { padding: 0.75rem 1rem; border-radius: var(--clickwrap-radius); }
+.clickwrap-flash--alert {
+ border: 2px solid var(--clickwrap-error);
+ border-left-width: 8px;
+ background: var(--clickwrap-error-surface);
+}
+
+/* --- Actions ------------------------------------------------------------------ */
+
+.clickwrap-actions { margin-top: 1.25rem; }
+
+.clickwrap-submit {
+ font: inherit;
+ padding: 0.6rem 1.1rem;
+ border-radius: var(--clickwrap-radius);
+ border: 1px solid var(--clickwrap-focus);
+ background: var(--clickwrap-focus);
+ color: #ffffff;
+ cursor: pointer;
+ min-height: 2.75rem;
+}
+
+/* --- Engine screens ------------------------------------------------------------
+ * Only the four pages this gem ships. A host page that embeds the form helper
+ * keeps its own layout entirely.
+ */
+
+.clickwrap-screen { max-width: 42rem; margin: 0 auto; padding: 1.5rem 1.25rem 3rem; }
+.clickwrap-screen__title { font-size: 1.5rem; margin: 0 0 0.35rem; }
+.clickwrap-screen__subtitle { font-size: 1.125rem; margin: 1.5rem 0 0.5rem; }
+.clickwrap-screen__intro { color: var(--clickwrap-muted); margin: 0 0 1.25rem; }
+.clickwrap-screen__actions { margin-top: 1.5rem; }
+
+.clickwrap-facts { display: grid; grid-template-columns: minmax(10rem, auto) 1fr; gap: 0.35rem 1rem; margin: 0; }
+.clickwrap-facts dt { color: var(--clickwrap-muted); }
+.clickwrap-facts dd { margin: 0; }
+
+.clickwrap-receipts, .clickwrap-acts { list-style: none; margin: 0; padding: 0; }
+
+.clickwrap-receipts__item,
+.clickwrap-acts__item {
+ padding: 0.75rem 0;
+ border-bottom: 1px solid rgba(0, 0, 0, 0.12);
+}
+
+.clickwrap-receipts__link { color: var(--clickwrap-link); text-decoration: underline; font-weight: 600; }
+.clickwrap-receipts__meta, .clickwrap-acts__meta { display: block; color: var(--clickwrap-muted); font-size: 0.875rem; }
+.clickwrap-acts__assertion { margin: 0; }
+
+.clickwrap-code {
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 0.8125rem;
+ word-break: break-all;
+}
+
+.clickwrap-code--digest { display: block; color: var(--clickwrap-muted); }
+.clickwrap-empty, .clickwrap-note { color: var(--clickwrap-muted); }
+.clickwrap-note { margin-top: 2rem; font-size: 0.875rem; }
+
+/* Respect a reader's own contrast and motion settings rather than overriding
+ them: in forced-colors mode the system palette wins outright. */
+@media (forced-colors: active) {
+ .clickwrap :is(a, button, input, [tabindex]):focus-visible { outline: 3px solid CanvasText; }
+ .clickwrap-error-summary, .clickwrap-flash--alert { border-color: CanvasText; }
+}
diff --git a/app/controllers/clickwrap/application_controller.rb b/app/controllers/clickwrap/application_controller.rb
new file mode 100644
index 0000000..7df3e25
--- /dev/null
+++ b/app/controllers/clickwrap/application_controller.rb
@@ -0,0 +1,79 @@
+# frozen_string_literal: true
+
+module Clickwrap
+ # Base controller for every engine screen. It inherits from the HOST's
+ # controller (`config.parent_controller_class_name`, "::ApplicationController"
+ # by default) so the host's layout, helpers, authentication filters, locale
+ # switching, and exception handling all apply to these screens for free — the
+ # same integration style as the sessions, chats, and api_keys gems.
+ #
+ # NOTE: the superclass is resolved when this class is AUTOLOADED, which in a
+ # booted app happens after initializers have run — so a
+ # `config.parent_controller_class_name` set in
+ # config/initializers/clickwrap.rb is honored. In development the class
+ # reloads on change and picks up configuration changes with it.
+ class ApplicationController < Clickwrap.config.parent_controller_class_name.constantize
+ # The view-layer DSL (`helper`, `helper_method`, `layout`) does not exist on
+ # ActionController::API, and an API-only host that bundles this gem for its
+ # model and service APIs still eager-loads this class in production, mounted
+ # or not. Guarding keeps such a host bootable; the HTML screens themselves
+ # still need a Base-derived parent controller, which is the default.
+ helper Clickwrap::EngineHelper if respond_to?(:helper)
+ helper_method :clickwrap_current_actor, :clickwrap_errors if respond_to?(:helper_method)
+
+ private
+
+ # The host's authentication filters run INSIDE these engine controllers —
+ # that is the entire point of inheriting from the host's parent controller —
+ # and those filters reference the HOST's own route helpers
+ # (`new_session_path` in the Rails authentication generator, custom
+ # redirects in hand-rolled filters), which an isolated engine's route set
+ # cannot resolve. Delegating unknown `*_path`/`*_url` calls to `main_app`
+ # lets the host's code work in here unmodified. It is the standard engine
+ # idiom, and the alternative is asking every host to special-case its own
+ # authentication for these four screens.
+ def method_missing(method, *, &)
+ if method.to_s.end_with?("_path", "_url") && main_app.respond_to?(method)
+ main_app.public_send(method, *, &)
+ else
+ super
+ end
+ end
+
+ def respond_to_missing?(method, include_private = false)
+ (method.to_s.end_with?("_path", "_url") && main_app.respond_to?(method)) || super
+ end
+
+ # A destination this application is willing to send someone back to.
+ #
+ # Return-to values arrive from the browser, so they are treated as untrusted
+ # navigation input: a relative path on this host, or nothing. An absolute
+ # URL, a protocol-relative "//evil.example", a scheme, or anything carrying
+ # control characters falls back to the default rather than being repaired
+ # into something that looks close enough.
+ def clickwrap_safe_return_to(candidate, fallback:)
+ value = candidate.to_s.strip
+ return fallback if value.empty?
+ return fallback unless value.start_with?("/")
+ return fallback if value.start_with?("//", "/\\")
+ return fallback if value.match?(/[[:cntrl:]]/)
+
+ uri = begin
+ URI.parse(value)
+ rescue URI::InvalidURIError
+ nil
+ end
+ return fallback if uri.nil? || uri.scheme.present? || uri.host.present?
+
+ value
+ end
+
+ # Actor-owned engine screens authorize only after a real actor exists.
+ # Otherwise a host callback such as `current_user == receipt.actor` could
+ # accidentally authorize the `nil == nil` case for imported or unlinked
+ # evidence.
+ def require_clickwrap_actor
+ head :unauthorized unless clickwrap_current_actor
+ end
+ end
+end
diff --git a/app/controllers/clickwrap/captures_controller.rb b/app/controllers/clickwrap/captures_controller.rb
new file mode 100644
index 0000000..2518944
--- /dev/null
+++ b/app/controllers/clickwrap/captures_controller.rb
@@ -0,0 +1,145 @@
+# frozen_string_literal: true
+
+module Clickwrap
+ # The standalone screen for one policy: the same server-owned presentation the
+ # form-builder helper renders inline, on a page of its own.
+ #
+ # This is what stops a required agreement from becoming a dead end. A gate
+ # redirects here, the person completes the policy in place, and they are
+ # returned to whatever they were trying to do. It is also the screen a host
+ # links to directly for a declaration that has expired or a new Terms version
+ # that needs accepting.
+ class CapturesController < ApplicationController
+ before_action :find_policy
+ before_action :require_clickwrap_actor
+ before_action :load_remediation_context
+ before_action :remember_return_destination
+
+ rescue_from RemediationInvalid, with: :remediation_not_found
+
+ def show
+ @presentation = present_policy
+ end
+
+ def create
+ capture_clickwrap!(@policy.key, subject: @remediation_subject,
+ acting_for: @remediation_represented_party,
+ **remediation_tenant_option)
+
+ redirect_to @return_to, allow_other_host: false, notice: t("clickwrap.captures.recorded")
+ rescue AnswerInvalid => error
+ re_present_with_error(error.statement_key, t("clickwrap.errors.answer_not_accepted"))
+ rescue SubmissionInvalid, PresentationExpired, PresentationInvalid, ReplayRejected,
+ OneTimeAuthorizationConflict
+ # A stale, replayed, or swapped presentation is not something to repair
+ # quietly: the server offers the policy again and the person answers the
+ # offer they can actually see.
+ re_present_with_error(nil, t("clickwrap.errors.presentation_no_longer_valid"))
+ rescue AuthorityNotVerified
+ # Authority is rechecked at submit. A role removed after the page was
+ # rendered must not become a 500 or a fresh offer that can never succeed.
+ head :forbidden
+ rescue RetryableTransactionError
+ response.set_header("Retry-After", "1")
+ re_present_with_error(nil, t("clickwrap.errors.temporarily_unavailable"), status: :service_unavailable)
+ end
+
+ private
+
+ def find_policy
+ @policy = Clickwrap.policy!(params[:policy_key])
+ rescue UnknownPolicyError
+ head :not_found
+ end
+
+ # Where to go after the policy is satisfied. The gate puts this in the URL
+ # and the form carries it through the POST; both are browser-supplied, so
+ # both go through the same safety check and fall back to this engine's own
+ # root rather than to anywhere interesting.
+ def remember_return_destination
+ candidate = @remediation_context&.return_to || params[:return_to]
+ @return_to = clickwrap_safe_return_to(candidate, fallback: clickwrap_engine_routes.root_path)
+ end
+
+ def load_remediation_context
+ token = params[:remediation_token].presence
+
+ if token.nil?
+ if @policy.subject_bound?
+ raise RemediationInvalid,
+ "This subject-bound policy needs a signed remediation route from the blocked action."
+ end
+
+ # A policy that permits acting for a represented party exists to record
+ # WHO was represented. Completing it on the bare engine screen with no
+ # represented party would write permanent evidence whose statements
+ # assert representative authority over nobody — orphan evidence that
+ # reads as more than it is. Those policies arrive here only through a
+ # signed remediation route that carries the represented party.
+ if @policy.authority_rule.present?
+ raise RemediationInvalid,
+ "This policy records representative authority, so it needs a signed remediation " \
+ "route naming the represented party. It cannot be completed standalone."
+ end
+
+ @remediation_token = nil
+ @remediation_subject = nil
+ @remediation_represented_party = nil
+ @remediation_tenant = nil
+ return
+ end
+
+ @remediation_context = resolve_clickwrap_remediation!(@policy.key, token: token)
+ @remediation_token = token
+ @remediation_subject = @remediation_context.subject
+ @remediation_represented_party = @remediation_context.represented_party
+ # The signed token carries the tenant the gate resolved; the engine's own
+ # routes have no ambient tenant, so this is the only truthful source.
+ @remediation_tenant = @remediation_context.tenant_reference.presence
+ end
+
+ def present_policy
+ present_clickwrap(
+ @policy.key,
+ subject: @remediation_subject,
+ acting_for: @remediation_represented_party,
+ locale: I18n.locale,
+ submit_button_text: submit_button_text,
+ **remediation_tenant_option
+ )
+ end
+
+ # Included only when a signed token carried a tenant: a token-less flow
+ # keeps the ordinary policy-aware ambient resolution, while a tokened flow
+ # must use exactly the tenant the issuing gate resolved and signed.
+ def remediation_tenant_option
+ @remediation_tenant.nil? ? {} : { tenant: @remediation_tenant }
+ end
+
+ # The words on the button, recorded in the manifest exactly as rendered. A
+ # host that wants different words translates the key; there is no way for
+ # the rendered button and the recorded text to disagree, because this is the
+ # only place either of them comes from.
+ def submit_button_text
+ t("clickwrap.captures.submit_button_text")
+ end
+
+ def re_present_with_error(statement_key, message, status: 422)
+ if statement_key
+ clickwrap_errors[statement_key.to_s] = message
+ else
+ flash.now[:alert] = message
+ end
+
+ # A new presentation, not the old one: its nonce is spent, and re-offering
+ # a spent token would fail again for a reason that has nothing to do with
+ # what the person got wrong.
+ @presentation = present_policy
+ render :show, status: status
+ end
+
+ def remediation_not_found
+ head :not_found
+ end
+ end
+end
diff --git a/app/controllers/clickwrap/document_versions_controller.rb b/app/controllers/clickwrap/document_versions_controller.rb
new file mode 100644
index 0000000..d8b8cd7
--- /dev/null
+++ b/app/controllers/clickwrap/document_versions_controller.rb
@@ -0,0 +1,71 @@
+# frozen_string_literal: true
+
+module Clickwrap
+ # The exact rendered bytes of one published document version.
+ #
+ # This is where every document link in every presentation points, and where an
+ # auditor reading a three-year-old receipt ends up. Both get the same response
+ # from the same row, verified against the digest that was recorded when it was
+ # published — `DocumentVersion#content_bytes` refuses to hand back bytes that
+ # no longer match, because silently serving edited content would turn this
+ # action into a way to launder a changed document into an old agreement.
+ #
+ # Retired versions stay reachable on purpose. A version stops being presentable
+ # when it is retired; it never stops being the thing somebody agreed to.
+ class DocumentVersionsController < ApplicationController
+ # Published legal documents are read before anyone is signed in — the signup
+ # form links to them, and that is the moment they matter most. The host's
+ # authentication filter is skipped here and only here. (`raise: false`
+ # because most hosts have neither of these filters; between them they cover
+ # the Rails authentication generator and Devise.)
+ skip_before_action :require_authentication, raise: false
+ skip_before_action :authenticate_user!, raise: false
+
+ def show
+ version = DocumentVersion.find_by(id: params[:id])
+ return head :not_found if version.nil? || !version.published?
+
+ # The linked representation is the exact rendered snapshot bound into the
+ # presentation, not mutable source and never raw unsanitized HTML.
+ response.headers["X-Content-Type-Options"] = "nosniff"
+ response.headers["Content-Security-Policy"] =
+ "default-src 'none'; img-src data:; style-src 'unsafe-inline'; " \
+ "base-uri 'none'; form-action 'none'; frame-ancestors 'self'; sandbox"
+ response.headers["Referrer-Policy"] = "no-referrer"
+ response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
+
+ # A published representation is derived from a specific source artifact.
+ # Refuse to serve either half of a version whose other half has stopped
+ # matching its publication digest; otherwise a corrupt source row could
+ # remain publicly vouched for merely because the rendered snapshot was
+ # untouched.
+ version.content_bytes
+ send_data version.rendered_bytes,
+ type: version.rendered_media_type.presence || version.media_type,
+ filename: download_filename(version, version.rendered_media_type.presence || version.media_type),
+ disposition: "inline"
+ rescue DocumentDigestMismatchError
+ # The stored bytes no longer match their recorded digest. Serving them
+ # anyway would be the one thing this action must never do.
+ head :unprocessable_entity
+ end
+
+ private
+
+ def download_filename(version, media_type)
+ base = [version.document&.document_key, version.version_label, version.locale].compact.join("-")
+
+ "#{base.parameterize}#{extension_for(media_type)}"
+ end
+
+ def extension_for(media_type)
+ case media_type.to_s.split(";", 2).first
+ when "text/markdown" then ".md"
+ when "text/html" then ".html"
+ when "text/plain" then ".txt"
+ when "application/pdf" then ".pdf"
+ else ""
+ end
+ end
+ end
+end
diff --git a/app/controllers/clickwrap/receipts_controller.rb b/app/controllers/clickwrap/receipts_controller.rb
new file mode 100644
index 0000000..f2edb77
--- /dev/null
+++ b/app/controllers/clickwrap/receipts_controller.rb
@@ -0,0 +1,115 @@
+# frozen_string_literal: true
+
+module Clickwrap
+ # "Show me exactly what the application recorded."
+ #
+ # Every screen here answers that question about one recorded event, and every
+ # screen here goes through the host's authorization callback to do it. There
+ # is no built-in "actors can always read their own" shortcut: the host decides
+ # who may read what, and the conventional initializer says so out loud.
+ #
+ # config.authorize_receipt_access_with = lambda do |controller, receipt|
+ # controller.current_user.present? &&
+ # (controller.current_user == receipt.actor || controller.current_user.admin?)
+ # end
+ #
+ # Until that is configured the default answers false, so an unconfigured host
+ # shows an empty list rather than leaking a receipt it never decided to share.
+ # A receipt the viewer may not see is NOT FOUND, never forbidden: a 403 tells
+ # an outsider that the id they guessed exists, and existence is itself
+ # information about someone.
+ class ReceiptsController < ApplicationController
+ before_action :require_clickwrap_actor
+
+ # An honest page size rather than an unbounded query. A production actor can
+ # accumulate years of retained history even when optional annex data is
+ # disposed on a separate schedule.
+ PER_PAGE = 50
+
+ # How many of the viewer's own rows this screen will read looking for
+ # PER_PAGE it may show. The host's callback is Ruby, not SQL, so the
+ # database cannot apply it and something has to bound the search. A viewer
+ # whose first thousand events are all unreadable to them is a host
+ # authorization question, not a paging question.
+ AUTHORIZATION_SCAN_LIMIT = 1_000
+
+ BATCH_SIZE = 100
+
+ def index
+ @events = authorized_page
+ end
+
+ def show
+ @event = find_readable_event
+ return head :not_found if @event.nil?
+
+ @receipt = @event.receipt
+
+ respond_to do |format|
+ format.html
+ format.json { render json: @receipt.to_canonical_json }
+ end
+ end
+
+ private
+
+ # Authorize, THEN paginate. Taking PER_PAGE rows and filtering afterwards
+ # renders an empty page whenever the viewer's newest fifty events happen to
+ # be ones the host will not show them — while readable receipts sit at row
+ # fifty-one. "Your receipts are gone" is a bad thing for this screen to say
+ # by accident.
+ #
+ # The actor is eager-loaded because the conventional callback compares
+ # `controller.current_user == receipt.actor`, which is one query per row
+ # otherwise. Reading in batches keeps that at a handful of queries for the
+ # whole page instead of one per receipt.
+ def authorized_page
+ authorized = []
+ scanned = 0
+
+ while authorized.length < page_size && scanned < authorization_scan_limit
+ batch = own_events.includes(:actor).offset(scanned).limit(batch_size).to_a
+ break if batch.empty?
+
+ authorized.concat(batch.select { |event| authorized_to_read?(event) })
+ scanned += batch.length
+ break if batch.length < batch_size
+ end
+
+ authorized.first(page_size)
+ end
+
+ # Readers rather than bare constants so a host that ejects this controller
+ # can page it differently by overriding one method.
+ def page_size = PER_PAGE
+ def batch_size = BATCH_SIZE
+ def authorization_scan_limit = AUTHORIZATION_SCAN_LIMIT
+
+ # The viewer's own events, newest first. Scoping by the actor reference the
+ # evidence itself carries — rather than by a foreign key that a deleted
+ # account would take with it — is what keeps this list working after a row
+ # has gone.
+ def own_events
+ Event.for_actor(actor_reference)
+ .order(recorded_at_by_server: :desc, id: :desc)
+ end
+
+ def actor_reference
+ Reference.actor(clickwrap_current_actor)
+ end
+
+ def find_readable_event
+ event = Event.find_by(id: params[:id])
+ return nil if event.nil?
+ return nil unless authorized_to_read?(event)
+
+ event
+ end
+
+ # The host's answer, treated as a plain yes/no. The default is no, so a host
+ # that has not made this decision yet cannot accidentally publish anything.
+ def authorized_to_read?(event)
+ !!Clickwrap.config.authorize_receipt_access_with.call(self, event.receipt)
+ end
+ end
+end
diff --git a/app/controllers/clickwrap/withdrawals_controller.rb b/app/controllers/clickwrap/withdrawals_controller.rb
new file mode 100644
index 0000000..c882ac4
--- /dev/null
+++ b/app/controllers/clickwrap/withdrawals_controller.rb
@@ -0,0 +1,60 @@
+# frozen_string_literal: true
+
+module Clickwrap
+ # Withdrawing one consent purpose.
+ #
+ # This screen is deliberately as short as the screen that granted the consent
+ # in the first place: one page, one control, one press. Withdrawal must be no
+ # harder than granting was — no support ticket, no email, no re-authentication
+ # the grant did not require, no confirmation maze. A gem that made taking
+ # consent back harder than giving it would be building the exact pattern it
+ # exists to prevent, so this controller has nothing in it but the two actions.
+ #
+ # Withdrawal APPENDS an event. It never deletes or edits the historical grant:
+ # what was true then stays recorded, and what is true now is that the person
+ # changed their mind. Only consent is withdrawable — withdrawing future
+ # processing does not rewrite a past agreement or a factual declaration.
+ class WithdrawalsController < ApplicationController
+ before_action :require_clickwrap_actor
+ before_action :find_purpose
+ before_action :remember_return_destination
+
+ def new; end
+
+ def create
+ Clickwrap.withdraw!(
+ @purpose_key,
+ actor: clickwrap_current_actor,
+ tenant: clickwrap_current_tenant,
+ http_request: request,
+ because: t("clickwrap.withdrawals.recorded_reason")
+ )
+
+ redirect_to @return_to, allow_other_host: false, notice: t("clickwrap.withdrawals.confirmed")
+ rescue AlreadyWithdrawnError
+ # Pressing the button twice is not an error worth showing a person. The
+ # purpose is withdrawn either way, which is what they asked for.
+ redirect_to @return_to, allow_other_host: false, notice: t("clickwrap.withdrawals.already_withdrawn")
+ rescue NotWithdrawableError => error
+ flash.now[:alert] = error.message
+ render :new, status: 422
+ end
+
+ private
+
+ # The purpose, not the policy: consent is purpose-specific, and a person
+ # withdrawing product updates is not withdrawing everything they ever did on
+ # the same screen.
+ def find_purpose
+ @purpose_key = params[:purpose_key].to_s
+
+ head :not_found if @purpose_key.empty?
+ end
+
+ # Browser-supplied navigation, checked the same way everywhere: a relative
+ # path on this host, or this engine's own root.
+ def remember_return_destination
+ @return_to = clickwrap_safe_return_to(params[:return_to], fallback: clickwrap_engine_routes.root_path)
+ end
+ end
+end
diff --git a/app/helpers/clickwrap/engine_helper.rb b/app/helpers/clickwrap/engine_helper.rb
new file mode 100644
index 0000000..5fa54d0
--- /dev/null
+++ b/app/helpers/clickwrap/engine_helper.rb
@@ -0,0 +1,97 @@
+# frozen_string_literal: true
+
+module Clickwrap
+ # View helpers, available BOTH inside the engine's own views and in the HOST
+ # app's views (mixed into ActionView by the hook at the bottom of this file,
+ # the same pattern the chats and moderate gems use).
+ #
+ # Everything here is prefixed `clickwrap_`, because these methods land in
+ # every view in the host application and a gem has no business claiming a
+ # short name in that namespace.
+ module EngineHelper
+ # The standalone capture screen for one policy — the remediation route:
+ #
+ # <%= link_to "Complete your declaration", clickwrap_capture_path(:contractor_declaration) %>
+ #
+ # Any extra options become query parameters, which is how a caller passes
+ # `return_to:` for a flow that should resume where it left off.
+ def clickwrap_capture_path(policy_key, **)
+ clickwrap_routes.capture_path(policy_key, **)
+ end
+
+ # A receipt, addressed by the event it belongs to. Takes a receipt, an
+ # event, or a bare event id, because all three turn up in host code.
+ def clickwrap_receipt_path(receipt, **)
+ clickwrap_routes.receipt_path(clickwrap_event_id_for(receipt), **)
+ end
+
+ # Where someone withdraws one consent purpose. Withdrawal is a first-class
+ # screen and not a buried mailto: link, because consent that cannot be
+ # withdrawn as easily as it was given is not something this gem will keep
+ # calling consent.
+ def clickwrap_withdrawal_path(purpose_key, **)
+ clickwrap_routes.withdrawal_path(purpose_key, **)
+ end
+
+ # The exact published bytes of one document version — what the presentation
+ # links to, and what an auditor reads later.
+ def clickwrap_document_version_path(version, **)
+ identifier = version.respond_to?(:id) ? version.id : version
+
+ clickwrap_routes.document_version_path(identifier, **)
+ end
+
+ # The gem's bundled stylesheet. Called from the engine's own views; hosts
+ # that eject and restyle the views simply stop including it.
+ def clickwrap_styles
+ stylesheet_link_tag "clickwrap", "data-turbo-track": "reload"
+ end
+
+ # Engine URL helpers that work from EVERY render context:
+ #
+ # * host views: the mounted proxy (`clickwrap.`) carries the mount prefix
+ # baked in at mount time, so URLs come out right;
+ # * engine views during requests: the engine's controllers inherit from
+ # the host's ApplicationController, so the proxy is available there too;
+ # * no mount at all (bare view tests): fall back to the engine's own
+ # url_helpers — prefix-less, but nothing better exists without a mount.
+ #
+ # NOTE: assumes the default mount name (`mount Clickwrap::Engine => "/x"`
+ # auto-names the proxy `clickwrap`). A host mounting with `as: :something`
+ # overrides this helper.
+ def clickwrap_routes
+ respond_to?(:clickwrap) ? clickwrap : Clickwrap::Engine.routes.url_helpers
+ end
+
+ # The host application's own routes, reachable from inside this isolated
+ # engine's views — where a bare `some_path` would be resolved against the
+ # engine's route set and explode.
+ def clickwrap_main_routes
+ respond_to?(:main_app) ? main_app : Rails.application.routes.url_helpers
+ end
+
+ private
+
+ def clickwrap_event_id_for(receipt)
+ return receipt.event_id if receipt.respond_to?(:event_id)
+ return receipt.id if receipt.respond_to?(:id)
+
+ receipt
+ end
+ end
+end
+
+# Expose the helpers to the HOST app's views (isolated engines don't share
+# helpers automatically). The hook lives HERE, at the bottom of the file that
+# defines the constant — not in an engine initializer — so it's self-resolving:
+# whenever this file loads (eager load, autoload on first use, or the engine's
+# to_prepare touch), the constant already exists by the time the hook can
+# possibly run. Registering it from an initializer instead would blow up at boot
+# in hosts where ActionView is already loaded during initializers (web-console
+# does this), because `include Clickwrap::EngineHelper` would fire before the
+# autoloader is ready.
+if defined?(ActiveSupport)
+ ActiveSupport.on_load(:action_view) do
+ include Clickwrap::EngineHelper
+ end
+end
diff --git a/app/views/clickwrap/captures/show.html.erb b/app/views/clickwrap/captures/show.html.erb
new file mode 100644
index 0000000..28c0f8d
--- /dev/null
+++ b/app/views/clickwrap/captures/show.html.erb
@@ -0,0 +1,34 @@
+<%#
+ The standalone capture screen: one policy, on a page of its own.
+
+ The form posts back to the SAME URL it was served from, so a failed
+ submission re-renders here — reloadable, shareable, and in the navigation
+ context the host intended, with or without JavaScript.
+%>
+<%= clickwrap_styles %>
+
+
+
+
<%= t("clickwrap.captures.title") %>
+
<%= t("clickwrap.captures.intro") %>
+
+
+ <% if flash.now[:alert].present? || flash[:alert].present? %>
+
diff --git a/app/views/clickwrap/receipts/index.html.erb b/app/views/clickwrap/receipts/index.html.erb
new file mode 100644
index 0000000..99dcf5b
--- /dev/null
+++ b/app/views/clickwrap/receipts/index.html.erb
@@ -0,0 +1,38 @@
+<%#
+ The viewer's own receipts, newest first.
+
+ Every row here passed the host's `authorize_receipt_access_with` callback. An
+ unconfigured host sees an empty list, which is the safe direction to be wrong
+ in: a receipt is only shown once somebody decided it should be.
+%>
+<%= clickwrap_styles %>
+
+
+
+
<%= t("clickwrap.receipts.index_title") %>
+
+
+ <% if @events.any? %>
+
+ <% @events.each do |event| %>
+
+
+ <%= event.policy_key %>
+
+ <%# Deliberately a plain UTC timestamp rather than `l()`: many hosts do
+ not bundle rails-i18n, and a time recorded by a server is clearest
+ when it is displayed without a silent timezone conversion. %>
+
+ <%= t("clickwrap.receipts.recorded_at_by_server") %>
+ <%= event.recorded_at_by_server&.utc&.strftime("%Y-%m-%d %H:%M UTC") %>
+
+
+ <%= t("clickwrap.event_types.#{event.event_type}", default: event.event_type.humanize) %>
+
+
+ <% end %>
+
+ <% else %>
+
<%= t("clickwrap.receipts.empty") %>
+ <% end %>
+
diff --git a/app/views/clickwrap/receipts/show.html.erb b/app/views/clickwrap/receipts/show.html.erb
new file mode 100644
index 0000000..fae0d15
--- /dev/null
+++ b/app/views/clickwrap/receipts/show.html.erb
@@ -0,0 +1,91 @@
+<%#
+ One receipt, in human-readable form. The canonical JSON is one link away and
+ is the verifiable artifact; this page is the projection of it a person can
+ read.
+
+ The closing note is part of the product, not a disclaimer bolted on: it says
+ exactly what this record does and does not show, in plain words, on the same
+ page as the record itself.
+%>
+<%= clickwrap_styles %>
+
+
+
+ <%# When the offer was one control carrying one composed sentence, the
+ acts below say what was recorded and this says what was read. %>
+ <% if (sentence = @event.presentation_manifest.dig("combined_control", "sentence")) %>
+
diff --git a/app/views/clickwrap/shared/_error_summary.html.erb b/app/views/clickwrap/shared/_error_summary.html.erb
new file mode 100644
index 0000000..d04a667
--- /dev/null
+++ b/app/views/clickwrap/shared/_error_summary.html.erb
@@ -0,0 +1,39 @@
+<%#
+ The error summary, at the top of the block where a person looking for what
+ went wrong will find it.
+
+ role="alert" so assistive technology announces it when the failed submission
+ re-renders. tabindex="-1" so it can hold focus, and `autofocus` so the browser
+ moves focus here on load without a line of JavaScript — this whole file has to
+ work when the page never runs a script.
+
+ Each entry links to the control it is about, so the fix is one press away
+ rather than a scroll and a hunt. One control gets one entry, whether it answers
+ one statement or a whole composed sentence: several lines pointing at the same
+ checkbox would be a list of the page's internals rather than of a person's
+ problems. The message is TEXT: the styling underlines it, but nothing here
+ depends on a color being seen.
+
+ Locals: presentation, entries — [{ control_id:, message: }]
+%>
+<% if entries.any? %>
+
+<% end %>
diff --git a/app/views/clickwrap/shared/_fields.html.erb b/app/views/clickwrap/shared/_fields.html.erb
new file mode 100644
index 0000000..add6d9a
--- /dev/null
+++ b/app/views/clickwrap/shared/_fields.html.erb
@@ -0,0 +1,100 @@
+<%#
+ The reference presentation partial.
+
+ Everything that renders a policy goes through this file: `form.clickwrap`,
+ `form.clickwrap_fields`, and the engine's own standalone capture screen. It is
+ plain ERB with no framework, no JavaScript, and no dependency beyond Rails'
+ own tag helpers, because it has to work unchanged inside a Tailwind app, a
+ Bootstrap app, a ViewComponent app, and an app with a hand-written stylesheet.
+
+ Eject it and it is yours:
+
+ bin/rails generate clickwrap:views
+
+ Your copy at app/views/clickwrap/shared/_fields.html.erb shadows this one
+ automatically — the helper renders the partial by NAME, never by path.
+
+ The default shape is ONE line:
+
+ [ ] I agree to the Terms of Service and I acknowledge the Privacy Policy.
+
+ The presenter decides whether a policy can be offered that way and signs the
+ exact sentence into the manifest when it can. Everything that line could not
+ honestly absorb — an optional consent, a recorded yes/no, copy the application
+ wrote itself — follows it as a control of its own, and a policy with nothing
+ composable is entirely that. Both go through the same statement partial, which
+ is why `itemized_statements` is the list to walk and `statements` is not:
+ rendering a second control for a statement the line already answers offers a
+ choice nobody has.
+
+ What this partial deliberately does NOT contain is a hidden field carrying a
+ server-owned decision. No IP address, no browser user-agent, no geolocation,
+ no policy version, no document digest, no validity window, no retention rule.
+ The only hidden field here is the signed presentation token, which is the
+ server's own statement about what it offered — the browser answers the offer,
+ it never writes it.
+
+ Locals:
+ presentation — a Clickwrap::Presenter::Result
+ submit — { text:, options: } to render the action here, or nil
+ after — markup the host rendered from the presentation itself
+ (the `form.clickwrap_fields do |clickwrap|` block), or nil
+ errors — { "statement_key" => "message" } from a failed capture
+ wrapper_options — HTML options for the wrapper element
+%>
+<%
+ errors = (local_assigns[:errors] || {}).to_h { |key, value| [key.to_s, value] }
+ submit = local_assigns[:submit]
+ after = local_assigns[:after]
+ wrapper_options = (local_assigns[:wrapper_options] || {}).symbolize_keys
+ wrapper_classes = ["clickwrap", "clickwrap-fields", wrapper_options.delete(:class)].compact
+
+ combined = presentation.combined
+ # One control shows one message, whether it answers one statement or three.
+ combined_error = combined && errors.values_at(*combined.statement_keys).compact.first
+ controls = [combined, *presentation.itemized_statements].compact
+
+ error_entries = controls.filter_map do |control|
+ message = control.equal?(combined) ? combined_error : errors[control.key]
+ next if message.blank?
+
+ { control_id: control.control_id, message: Array(message).join(" ") }
+ end
+%>
+<%= tag.div(**wrapper_options, class: wrapper_classes) do %>
+ <%= render "clickwrap/shared/error_summary",
+ presentation: presentation,
+ entries: error_entries %>
+
+ <%# The server's statement of what it offered, signed and short-lived. %>
+ <%= hidden_field_tag "clickwrap_submission[presentation_token]", presentation.token, id: nil %>
+
+
+
+ <%# The action, rendered from the same presentation as the controls above it,
+ so the words recorded in the manifest are the words on the button. Document
+ links are above this point, always: a link that only appears after the call
+ to action has already been pressed is not a link to anything. %>
+ <% if submit %>
+
+ <% end %>
+
+ <%# The host's own action, rendered by its own markup from this same
+ presentation. It sits here, after the controls and after the document
+ links, for exactly the reason above. %>
+ <% if after.present? %>
+
<%= after %>
+ <% end %>
+<% end %>
diff --git a/app/views/clickwrap/shared/_statement.html.erb b/app/views/clickwrap/shared/_statement.html.erb
new file mode 100644
index 0000000..627e18f
--- /dev/null
+++ b/app/views/clickwrap/shared/_statement.html.erb
@@ -0,0 +1,105 @@
+<%#
+ ONE CONTROL: one checkbox or one choice group, one label carrying the exact
+ words the server offered, the documents it is about, and its own error.
+
+ It renders two things, the same way. A single statement — an optional consent,
+ a recorded yes/no, an assertion the application worded itself, anything a
+ policy's composed line could not honestly absorb — and the composed line
+ itself, which is one control standing for several statements at once. Both
+ answer the same questions about a control (name, id, required, choices, error)
+ and this file asks nothing else of them.
+
+ Rules this file exists to keep:
+
+ * a control means exactly what the server signed it means. The composed line
+ is one box for several statements ONLY because the manifest says which
+ ones and the server answers all of them from it; two boxes answering one
+ statement each is the shape nothing can reconstruct later;
+ * the control is REAL and starts EMPTY. There is no `checked` anywhere in
+ this file and there never will be: a pre-ticked box records the page's
+ default rather than a person's action;
+ * the label contains the words and is tied to the control, so pressing them
+ works and assistive technology announces them together;
+ * document links come BEFORE the action and carry the document's own name
+ (not "click here");
+ * `required` is progressive enhancement only. THE SERVER DECIDES. A browser
+ that ignores it, a client that never sends the field, and a script that
+ posts by hand all meet the same server-side check at capture — which is
+ also why nothing here prints the word "Required" beside a control.
+
+ Locals: statement, error
+%>
+<%
+ error = local_assigns[:error].presence
+ described_by = error ? statement.error_id : nil
+ statement_classes = ["clickwrap-statement", "clickwrap-statement--#{statement.kind}"]
+ statement_classes << "clickwrap-statement--invalid" if error
+%>
+
">
+ <% if statement.choices.present? %>
+ <%# An explicit yes/no decision: every option unselected, so the answer is
+ one the person actually made rather than one the page made for them. %>
+ <%
+ fieldset_options = { class: "clickwrap-statement__choices" }
+ fieldset_options[:"aria-invalid"] = "true" if error
+ fieldset_options[:"aria-describedby"] = described_by if described_by
+ %>
+ <%= tag.fieldset(**fieldset_options) do %>
+
+
+ <% statement.choices.each_key do |choice| %>
+ <%
+ choice_id = "#{statement.control_id}_#{choice}"
+ choice_options = { id: choice_id, class: "clickwrap-statement__radio" }
+ choice_options[:required] = true if statement.requires_an_explicit_choice?
+ choice_options[:"aria-describedby"] = described_by if described_by
+ %>
+
+ <%= radio_button_tag statement.control_name, choice, false, choice_options %>
+ <%= label_tag choice_id,
+ t("clickwrap.choices.#{choice}", default: choice.to_s.humanize),
+ class: "clickwrap-statement__choice-label" %>
+
+ <% end %>
+ <% end %>
+ <% else %>
+ <%
+ control_options = { id: statement.control_id, class: "clickwrap-statement__checkbox" }
+ control_options[:required] = true if statement.required?
+ control_options[:"aria-invalid"] = "true" if error
+ control_options[:"aria-describedby"] = described_by if described_by
+ %>
+
+ <% end %>
+
+ <%# Only for a statement standing on its own. The composed line's links are
+ inside its sentence, so it has none to list here. %>
+ <% if statement.documents.any? %>
+
+ <% statement.documents.each do |document| %>
+
<%= clickwrap_document_link(document) %>
+ <% end %>
+
+ <% end %>
+
+ <% if error %>
+ <%# Announced through aria-describedby above, and readable as ordinary text
+ here: the meaning is in the words, not in the color they are printed in. %>
+
diff --git a/app/views/clickwrap/withdrawals/new.html.erb b/app/views/clickwrap/withdrawals/new.html.erb
new file mode 100644
index 0000000..7fe9eda
--- /dev/null
+++ b/app/views/clickwrap/withdrawals/new.html.erb
@@ -0,0 +1,30 @@
+<%#
+ Withdrawing one consent purpose: one sentence, one button.
+
+ Nothing is added to this page. Withdrawal must be no harder than granting was,
+ and every extra step here would be a step someone did not have to take to say
+ yes.
+%>
+<%= clickwrap_styles %>
+
+
diff --git a/bin/console b/bin/console
new file mode 100755
index 0000000..01594aa
--- /dev/null
+++ b/bin/console
@@ -0,0 +1,11 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+require "bundler/setup"
+require "clickwrap"
+
+# You can add fixtures and/or initialization code here to make experimenting
+# with your gem easier. You can also use a different console, if you like.
+
+require "irb"
+IRB.start(__FILE__)
diff --git a/bin/setup b/bin/setup
new file mode 100755
index 0000000..dce67d8
--- /dev/null
+++ b/bin/setup
@@ -0,0 +1,8 @@
+#!/usr/bin/env bash
+set -euo pipefail
+IFS=$'\n\t'
+set -vx
+
+bundle install
+
+# Do any other automated setup that you need to do here
diff --git a/clickwrap.gemspec b/clickwrap.gemspec
index eb61807..db4fbda 100644
--- a/clickwrap.gemspec
+++ b/clickwrap.gemspec
@@ -8,38 +8,56 @@ Gem::Specification.new do |spec|
spec.authors = ["rameerez"]
spec.email = ["rubygems@rameerez.com"]
- spec.summary = "Name reservation: clickwrap is not implemented or published yet"
- spec.description = "This release holds the clickwrap gem name while the product is being defined; it is deliberately empty. It ships no engine, no models, no migrations, no generators, and no public API, and declares no runtime dependencies. Do not depend on it. The intended gem is an evidence-and-assent layer for Rails covering agreements, consent, declarations, and authorizations, and its README describes that intended design as a contract to build against, not as code that exists today. A functional release will be published as 0.1.0 or later."
+ spec.summary = "Make your Rails users accept your Terms and legal documents — with standalone-verifiable receipts"
+ spec.description = "clickwrap turns Terms acceptance, privacy notice acknowledgments, consent, declarations, attestations, and one-time authorizations into one Rails primitive: frozen document versions, server-owned policies, atomic evidence capture (the evidence and the protected action commit in the same transaction), and canonical receipts with a standalone verifier. Consent can actually be withdrawn, declarations expire without rewriting history, authorizations are consumed once, and optional IP address, browser user-agent, and IP geolocation evidence stays off by default. No JavaScript package, no Redis, no background jobs, no external services — just Rails and your database. clickwrap provides evidence mechanics only: your application and its counsel own the legal text, lawful basis, and retention periods."
spec.homepage = "https://github.com/rameerez/clickwrap"
spec.license = "MIT"
- spec.required_ruby_version = ">= 3.1.0"
+ spec.required_ruby_version = ">= 3.2.0"
spec.metadata["allowed_push_host"] = "https://rubygems.org"
- spec.metadata["homepage_uri"] = spec.homepage
- spec.metadata["source_code_uri"] = "#{spec.homepage}/tree/main"
+ spec.metadata["source_code_uri"] = spec.homepage
spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
spec.metadata["bug_tracker_uri"] = "#{spec.homepage}/issues"
+ spec.metadata["documentation_uri"] = "#{spec.homepage}#readme"
spec.metadata["rubygems_mfa_required"] = "true"
gemspec = File.basename(__FILE__)
+ development_files = %w[.simplecov AGENTS.md Appraisals CLAUDE.md Rakefile context7.json]
spec.files = IO.popen(%w[git ls-files -z], chdir: __dir__, err: IO::NULL) do |ls|
- ls.readlines("\x0", chomp: true).reject do |file|
- (file == gemspec) ||
- file.start_with?(*%w[
- .github/
- bin/
- gemfiles/
- spec/
- test/
- ]) ||
- %w[
- .gitignore
- AGENTS.md
- ].include?(file)
+ ls.readlines("\x0", chomp: true).reject do |f|
+ (f == gemspec) || development_files.include?(f) ||
+ f.start_with?(*%w[bin/ gemfiles/ test/ spec/ features/ .git .github appveyor Gemfile])
end
end
-
+ spec.bindir = "exe"
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
spec.require_paths = ["lib"]
- # No runtime dependencies: there is nothing here to depend on anything yet.
+ # Runtime dependencies are the Rails components the approved surface actually
+ # uses, never the `rails` meta-gem. Clickwrap's whole value is long-lived
+ # evidence in your own database, so installing it must not drag in
+ # infrastructure you then have to keep alive for the evidence to stay
+ # readable. Deliberately NOT dependencies, and never required at runtime:
+ # Devise, `trackdown`, Active Storage, Active Job, a job backend, Redis, a
+ # PDF library, a JavaScript runtime, a CSS framework, an HTTP client, or any
+ # external service. Every one of those is an optional adapter with a working
+ # no-op default.
+ spec.add_dependency "actionpack", ">= 7.1.0", "< 9.0"
+ spec.add_dependency "actionview", ">= 7.1.0", "< 9.0"
+ spec.add_dependency "activerecord", ">= 7.1.0", "< 9.0"
+ spec.add_dependency "activesupport", ">= 7.1.0", "< 9.0"
+ spec.add_dependency "railties", ">= 7.1.0", "< 9.0"
+
+ # The two below were always runtime dependencies — `lib/clickwrap.rb` requires
+ # the document renderer at boot, and the renderer requires `loofah` and
+ # `rails-html-sanitizer` — and they resolved only because Action View happens
+ # to depend on them. Declaring them adds nothing to anybody's bundle; it
+ # states what this gem already loads, so the day Action View's dependency
+ # graph changes, clickwrap does not discover it in production.
+ #
+ # They are not optional, either: sanitizing is how a stored document becomes
+ # a representation that can be offered, and its identity and version are
+ # recorded in the provenance beside the digest.
+ spec.add_dependency "loofah", ">= 2.21", "< 3.0"
+ spec.add_dependency "rails-html-sanitizer", ">= 1.6", "< 2.0"
end
diff --git a/config/locales/en.yml b/config/locales/en.yml
new file mode 100644
index 0000000..46530b3
--- /dev/null
+++ b/config/locales/en.yml
@@ -0,0 +1,160 @@
+# The gem's own English strings.
+#
+# Two things to know before changing anything in here:
+#
+# 1. Wording is product design, and some of these sentences carry meaning the
+# rest of the gem depends on. A person AGREES TO Terms and ACKNOWLEDGES a
+# Privacy Notice — a privacy notice is information someone is entitled to,
+# not a bargain they strike, and "I agree to the privacy policy" quietly
+# turns one into the other. Keep the verbs matched to the kinds.
+# 2. Nothing here may claim, or read as claiming, that anything is compliant,
+# enforceable, legally binding, court-proof, or that a person read or
+# understood a document. These strings appear on screens and in receipts,
+# which is exactly where an overclaim would do damage.
+#
+# Hosts override any of this by defining the same key in their own locale files
+# (the application's translations load last and win), and a policy can name its
+# own key or literal text instead.
+en:
+ clickwrap:
+ # Conventional first-person assertions. `agree_to :terms` with no
+ # `statement:` resolves to clickwrap.statements.agreement.terms; a policy
+ # that says something more specific passes its own text or key.
+ statements:
+ agreement:
+ terms: "I agree to the Terms."
+ acknowledgment:
+ privacy_notice: "I acknowledge the Privacy Notice."
+
+ # The composed one-line offer. When every statement in a policy is an
+ # ordinary, required, default-worded agreement or acknowledgment, they
+ # render as ONE control carrying ONE sentence, with the documents linked
+ # inside it:
+ #
+ # I agree to the Terms of Service and I acknowledge the Privacy Policy.
+ #
+ # One fragment per kind, and the verbs stay matched to the kinds for the
+ # reason at the top of this file. `%{documents}` is where the links go;
+ # a fragment without it composes nothing and the statements render on
+ # lines of their own, which is also what happens in any locale that has
+ # not translated these four keys.
+ sentence:
+ agreement: "I agree to the %{documents}"
+ acknowledgment: "I acknowledge the %{documents}"
+ # Between two documents on ONE statement ("the Terms and the Schedules"),
+ # between two fragments, and at the end of the whole line.
+ documents_joiner: " and the "
+ joiner: " and "
+ terminator: "."
+
+ ui:
+ error_summary_heading: "Check these before continuing"
+ error_prefix: "Error:"
+ # Announced, never drawn. A sighted person can see the icon their browser
+ # gives a new tab; a screen-reader user cannot, and neither of them needs
+ # the words printed beside every link. Rendered only when the link really
+ # does open a new tab, because a same-window link claiming otherwise would
+ # be the page lying about itself.
+ opens_in_new_tab: "(opens in a new tab)"
+ # Receipts only. A version label beside a checkbox is a fact nobody can
+ # act on; a version label on the record is the whole point of the record.
+ document_version: "Version %{version}"
+ withdrawal_link: "Withdraw %{purpose} at any time"
+
+ # Labels for an explicit yes/no consent choice. Quoted because unquoted
+ # `yes:` and `no:` are booleans in YAML, not the words.
+ choices:
+ "yes": "Yes"
+ "no": "No"
+ grant: "Yes"
+ decline: "No"
+
+ errors:
+ required_statement: "You need to answer this before continuing."
+ answer_not_accepted: "This answer was not accepted. Please check it and try again."
+ presentation_no_longer_valid: "This page expired before it was submitted, so it has been
+ offered again. Please check your answers and submit once more."
+ temporarily_unavailable: "This could not be recorded because the database was temporarily
+ busy. Nothing was accepted. Please check your answers and try again."
+
+ captures:
+ title: "Before you continue"
+ intro: "Please look over the documents below and make your choices. Nothing is selected for
+ you."
+ submit_button_text: "Continue"
+ recorded: "Thank you — that has been recorded."
+
+ withdrawals:
+ title: "Withdraw %{purpose}"
+ explanation: "You can withdraw this at any time, and it takes one press — the same as giving
+ it did."
+ confirm_button: "Withdraw"
+ confirmed: "That purpose has been withdrawn."
+ already_withdrawn: "That purpose was already withdrawn."
+ recorded_reason: "The person withdrew this purpose from the consent withdrawal screen."
+ what_this_does: "Withdrawing records that you have withdrawn this purpose from now on. It
+ adds a new record rather than deleting the earlier one, so the history stays accurate."
+
+ receipts:
+ index_title: "Your records"
+ show_title: "Record"
+ empty: "There is nothing here yet."
+ policy: "Policy"
+ event_id: "Record ID"
+ event_type: "What happened"
+ recorded_at_by_server: "Recorded by the server at"
+ submit_button_text: "Call to action in the server offer"
+ combined_sentence: "Sentence the server offered, in one control"
+ acts: "Recorded statements and answers"
+ documents: "Document versions bound to this record"
+ offered_and_not_taken: "offered, not taken"
+ canonical_json: "Download the canonical JSON"
+ what_this_records: "This record shows the exact wording and document versions this application
+ generated as an offer, and what was submitted back. It does not show that anyone read or
+ understood the documents, and it is not a legal opinion about them."
+
+ # The six kinds, for display. This taxonomy is product design; naming an act
+ # an agreement or a consent does not decide its legal effect.
+ kinds:
+ agreement: "Agreement"
+ acknowledgment: "Acknowledgment"
+ consent: "Consent"
+ declaration: "Declaration"
+ attestation: "Attestation"
+ authorization: "Authorization"
+
+ actions:
+ agreed: "Agreed"
+ acknowledged: "Acknowledged"
+ granted: "Granted"
+ declined: "Declined"
+ declared: "Declared"
+ attested: "Attested"
+ authorized: "Authorized"
+ withdrawn: "Withdrawn"
+ renewed: "Renewed"
+ scope_changed: "Scope changed"
+ superseded: "Superseded"
+ expired: "Expired"
+ consumed: "Used"
+ revoked: "Revoked"
+ corrected: "Corrected"
+
+ event_types:
+ capture: "Submitted"
+ withdrawal: "Withdrawn"
+ correction: "Corrected"
+ supersession: "Superseded"
+ expiry: "Expired"
+ consumption: "Used"
+ revocation: "Revoked"
+ renewal: "Renewed"
+ scope_change: "Scope changed"
+ exemption: "Recorded exemption — no human action"
+ imported_legacy: "Imported from earlier records"
+ external_receipt: "Recorded by another provider"
+ disposition: "Disposed of under a retention rule"
+ legal_hold_placed: "Placed on hold"
+ legal_hold_released: "Hold released"
+ receipt_access: "Record accessed"
+ provider_outcome: "Provider outcome recorded"
diff --git a/config/locales/es.yml b/config/locales/es.yml
new file mode 100644
index 0000000..410f0a7
--- /dev/null
+++ b/config/locales/es.yml
@@ -0,0 +1,138 @@
+# The gem's own Spanish strings.
+#
+# The same two rules as en.yml, which is the reference file:
+#
+# 1. Verbs stay matched to the kinds. A person AGREES TO Terms ("acepto") and
+# ACKNOWLEDGES a Privacy Notice ("doy por recibida") — a privacy notice is
+# information someone is entitled to, not a bargain they strike, and
+# "acepto la política de privacidad" quietly turns one into the other
+# (this is the AEPD's own distinction; see the request-evidence guide).
+# 2. Nothing here may claim, or read as claiming, that anything is compliant,
+# enforceable, legally binding, or that a person read or understood a
+# document. "He leído..." is deliberately absent.
+#
+# Grammatical gender: forms address the person directly ("tú") and avoid
+# gendered self-descriptions ("informado/a"), so every string works for
+# everyone without slashed suffixes.
+es:
+ clickwrap:
+ statements:
+ agreement:
+ terms: "Acepto los Términos."
+ acknowledgment:
+ privacy_notice: "Doy por recibida la Política de Privacidad."
+
+ # La frase de una sola línea. En español el artículo concuerda con el
+ # documento ("los Términos", "la Política"), y solo la aplicación sabe qué
+ # documento nombra, así que el artículo vive en la etiqueta del enlace y no
+ # en estas plantillas — por eso `documents_joiner` es " y " y no " y el ".
+ sentence:
+ agreement: "Acepto %{documents}"
+ acknowledgment: "He recibido %{documents}"
+ documents_joiner: " y "
+ joiner: " y "
+ terminator: "."
+
+ ui:
+ error_summary_heading: "Revisa esto antes de continuar"
+ error_prefix: "Error:"
+ opens_in_new_tab: "(se abre en una pestaña nueva)"
+ document_version: "Versión %{version}"
+ withdrawal_link: "Retira %{purpose} cuando quieras"
+
+ choices:
+ "yes": "Sí"
+ "no": "No"
+ grant: "Sí"
+ decline: "No"
+
+ errors:
+ required_statement: "Debes responder esto antes de continuar."
+ answer_not_accepted: "Esta respuesta no se ha aceptado. Revísala e inténtalo de nuevo."
+ presentation_no_longer_valid: "Esta página caducó antes de enviarse, así que se ha vuelto
+ a mostrar. Revisa tus respuestas y envíala otra vez."
+ temporarily_unavailable: "No se ha podido registrar porque la base de datos estaba
+ temporalmente ocupada. No se ha aceptado nada. Revisa tus respuestas e inténtalo de nuevo."
+
+ captures:
+ title: "Antes de continuar"
+ intro: "Revisa los documentos siguientes y marca tus respuestas. No hay nada seleccionado
+ de antemano."
+ submit_button_text: "Continuar"
+ recorded: "Gracias — ha quedado registrado."
+
+ withdrawals:
+ title: "Retirar %{purpose}"
+ explanation: "Puedes retirarlo cuando quieras, y cuesta una sola pulsación — igual que
+ costó darlo."
+ confirm_button: "Retirar"
+ confirmed: "Ese permiso ha quedado retirado."
+ already_withdrawn: "Ese permiso ya estaba retirado."
+ recorded_reason: "La persona retiró este permiso desde la pantalla de retirada de
+ consentimiento."
+ what_this_does: "Retirarlo registra que a partir de ahora has retirado este permiso. Se
+ añade un registro nuevo en lugar de borrar el anterior, así el historial sigue siendo
+ exacto."
+
+ receipts:
+ index_title: "Tus registros"
+ show_title: "Registro"
+ empty: "Aquí no hay nada todavía."
+ policy: "Política"
+ event_id: "ID del registro"
+ event_type: "Qué ocurrió"
+ recorded_at_by_server: "Registrado por el servidor el"
+ submit_button_text: "Botón de acción de la oferta del servidor"
+ combined_sentence: "Frase que ofreció el servidor, en una sola casilla"
+ acts: "Declaraciones y respuestas registradas"
+ documents: "Versiones de documentos vinculadas a este registro"
+ offered_and_not_taken: "ofrecido, no marcado"
+ canonical_json: "Descargar el JSON canónico"
+ what_this_records: "Este registro muestra la redacción y las versiones de documentos
+ exactas que esta aplicación generó como oferta, y lo que se envió de vuelta. No
+ demuestra que nadie leyera o entendiera los documentos, y no es una opinión legal
+ sobre ellos."
+
+ kinds:
+ agreement: "Acuerdo"
+ acknowledgment: "Acuse de recibo"
+ consent: "Consentimiento"
+ declaration: "Declaración"
+ attestation: "Certificación"
+ authorization: "Autorización"
+
+ actions:
+ agreed: "Aceptado"
+ acknowledged: "Recibido"
+ granted: "Concedido"
+ declined: "Rechazado"
+ declared: "Declarado"
+ attested: "Certificado"
+ authorized: "Autorizado"
+ withdrawn: "Retirado"
+ renewed: "Renovado"
+ scope_changed: "Alcance modificado"
+ superseded: "Sustituido"
+ expired: "Caducado"
+ consumed: "Utilizado"
+ revoked: "Revocado"
+ corrected: "Corregido"
+
+ event_types:
+ capture: "Enviado"
+ withdrawal: "Retirado"
+ correction: "Corregido"
+ supersession: "Sustituido"
+ expiry: "Caducado"
+ consumption: "Utilizado"
+ revocation: "Revocado"
+ renewal: "Renovado"
+ scope_change: "Alcance modificado"
+ exemption: "Exención registrada — sin acción humana"
+ imported_legacy: "Importado de registros anteriores"
+ external_receipt: "Registrado por otro proveedor"
+ disposition: "Eliminado según una regla de retención"
+ legal_hold_placed: "Puesto en retención legal"
+ legal_hold_released: "Retención legal levantada"
+ receipt_access: "Registro consultado"
+ provider_outcome: "Resultado del proveedor registrado"
diff --git a/config/routes.rb b/config/routes.rb
new file mode 100644
index 0000000..df15886
--- /dev/null
+++ b/config/routes.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+# The engine's own routes, drawn under whatever the host mounted it at:
+#
+# mount Clickwrap::Engine => "/agreements"
+#
+# Four surfaces, and nothing else: complete a policy, read a receipt, withdraw a
+# consent, read a document version. They exist so that a required agreement, an
+# expired declaration, or a consent someone wants back is resolvable IN PLACE
+# instead of turning into a support ticket.
+#
+# Every screen has BOTH a GET and a form-action path at the same URL. That is
+# deliberate: a validation failure re-renders under the address the browser is
+# already on, so a no-JavaScript submission, a Turbo Drive visit, and a Hotwire
+# Native web screen all stay in the navigation context the host intended,
+# instead of landing on a POST-only URL that cannot be reloaded or shared.
+Clickwrap::Engine.routes.draw do
+ # Policy keys, consent purposes, and document version ids are plain
+ # identifiers. Constraining them keeps a path segment from carrying something
+ # shaped like a path.
+ identifier = %r{[^/.]+}
+
+ get "policies/:policy_key", to: "captures#show", as: :capture,
+ constraints: { policy_key: identifier }
+ post "policies/:policy_key", to: "captures#create", as: :capture_submission,
+ constraints: { policy_key: identifier }
+
+ get "consents/:purpose_key/withdrawal", to: "withdrawals#new", as: :withdrawal,
+ constraints: { purpose_key: identifier }
+ post "consents/:purpose_key/withdrawal", to: "withdrawals#create", as: :withdrawal_submission,
+ constraints: { purpose_key: identifier }
+
+ resources :receipts, only: %i[index show]
+
+ # What every document link in every presentation points at, and what an
+ # auditor opens years later: one immutable published version, by id.
+ get "documents/:id", to: "document_versions#show", as: :document_version,
+ constraints: { id: identifier }
+
+ root to: "receipts#index"
+end
diff --git a/context7.json b/context7.json
new file mode 100644
index 0000000..1a32602
--- /dev/null
+++ b/context7.json
@@ -0,0 +1,4 @@
+{
+ "url": "https://context7.com/rameerez/clickwrap",
+ "public_key": "pk_HibNJE5rTFvy1txHHXUot"
+}
diff --git a/exe/clickwrap b/exe/clickwrap
new file mode 100755
index 0000000..fae9fea
--- /dev/null
+++ b/exe/clickwrap
@@ -0,0 +1,374 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+# The standalone verifier.
+#
+# clickwrap verify receipt.json --documents ./receipt-documents
+#
+# ==============================================================================
+# THIS COMMAND MUST WORK WITH NOTHING. No Rails, no host application, no
+# database, no configuration, no network, and no access to the code that wrote
+# the receipt. That is the entire point of it: a receipt whose only verifier is
+# the application that produced it is a receipt whose verification nobody
+# independent can repeat. Someone should be able to install this gem on a laptop
+# years from now, point it at a JSON file and a folder of documents, and get an
+# answer.
+#
+# So this file requires four things and nothing else: canonical JSON, the digest
+# helpers, the receipt verifier, and the standard library. Adding an
+# ActiveSupport call, a Rails constant, or a gem dependency here would quietly
+# break the promise the receipt itself makes in its `verifier_instructions`.
+# ==============================================================================
+#
+# What a successful run establishes is bounded, and the output says so: the
+# bytes in this file still hash to the digest recorded inside it, and the
+# document files supplied still hash to the digests the receipt cites. It does
+# not establish who wrote the receipt, when, or that whoever controlled the
+# application and its database could not have written both the record and its
+# digest.
+
+lib = File.expand_path("../lib", __dir__)
+$LOAD_PATH.unshift(lib) if File.directory?(lib) && !$LOAD_PATH.include?(lib)
+
+require "json"
+
+require "clickwrap/version"
+require "clickwrap/errors"
+require "clickwrap/canonical_json"
+require "clickwrap/digest"
+
+# The command-line front end. Argument parsing by hand rather than through
+# OptionParser: there is one command with one option, and a hand-written parser
+# is easier to read than the configuration of a general one.
+module ClickwrapCommandLine
+ SUCCESS = 0
+ FAILURE = 1
+ INCOMPLETE = 2
+
+ # Three states, not two. A check that could not run — a document whose bytes
+ # nobody supplied — is neither a pass nor a failure, and collapsing it into
+ # either one turns "we did not look" into "we looked and it was fine".
+ SYMBOLS = { true => "✓", false => "✗", nil => "–" }.freeze
+
+ USAGE = <<~TEXT.freeze
+ clickwrap #{Clickwrap::VERSION} — verify a Clickwrap receipt without the application that wrote it.
+
+ Usage:
+ clickwrap verify RECEIPT.json [--documents DIR] [--json]
+ clickwrap --help
+ clickwrap --version
+
+ Options:
+ --documents DIR Folder holding BOTH artifacts for every cited document. Name them
+ KEY-VERSION-LOCALE.source.EXT and
+ KEY-VERSION-LOCALE.rendered.EXT. The source and the exact rendered
+ representation are checked against their separate recorded digests.
+ --json Print the result as JSON instead of lines of text.
+
+ Exit status:
+ 0 every required check passed
+ 1 a check failed or the receipt could not be read
+ 2 no check failed, but at least one required artifact was not supplied
+
+ What a pass means: the canonical bytes still hash to the digest recorded inside the
+ receipt, and the documents supplied still hash to the digests it cites. It does not
+ establish who produced the receipt, when, or that a party controlling the original
+ application and its database could not have written both the record and its digest.
+ TEXT
+
+ module_function
+
+ def run(argv)
+ return help if argv.empty? || argv.first == "--help" || argv.first == "-h"
+ return version if ["--version", "-v"].include?(argv.first)
+
+ command = argv.shift
+
+ case command
+ when "verify" then verify(argv)
+ else
+ warn("Unknown command #{command.inspect}.\n\n#{USAGE}")
+ FAILURE
+ end
+ end
+
+ def help
+ say(USAGE)
+ SUCCESS
+ end
+
+ def version
+ say("clickwrap #{Clickwrap::VERSION} (receipt schema #{Clickwrap::CANONICAL_SCHEMA_VERSION})")
+ SUCCESS
+ end
+
+ def verify(argv)
+ options = parse_verify_options(argv)
+ return FAILURE if options.nil?
+
+ receipt_json = read_receipt(options[:receipt_path])
+ return FAILURE if receipt_json.nil?
+
+ parsed = parse_json(receipt_json, options[:receipt_path])
+ return FAILURE if parsed.nil?
+
+ documents, sources = load_documents(parsed, options[:documents_directory])
+ return FAILURE if documents.nil?
+
+ result = verifier.verify(receipt_json, documents: documents)
+ options[:json] ? report_as_json(parsed, result, sources) : report(parsed, result, sources)
+ return FAILURE if result.failed?
+ return INCOMPLETE if result.incomplete?
+
+ SUCCESS
+ rescue Clickwrap::Error => error
+ warn("#{error.class.name.split("::").last}: #{error.message}")
+ FAILURE
+ end
+
+ # --- Arguments ------------------------------------------------------------
+
+ def parse_verify_options(argv)
+ options = { receipt_path: nil, documents_directory: nil, json: false }
+
+ until argv.empty?
+ argument = argv.shift
+
+ case argument
+ when "--documents"
+ options[:documents_directory] = argv.shift
+ when /\A--documents=(.+)\z/
+ options[:documents_directory] = Regexp.last_match(1)
+ when "--json"
+ options[:json] = true
+ else
+ options[:receipt_path] = argument
+ end
+ end
+
+ return options if options[:receipt_path]
+
+ warn("clickwrap verify needs the path to a receipt file.\n\n#{USAGE}")
+ nil
+ end
+
+ # --- Reading --------------------------------------------------------------
+
+ def read_receipt(path)
+ unless File.file?(path)
+ warn("There is no file at #{path}.")
+ return nil
+ end
+
+ File.read(path, encoding: Encoding::UTF_8)
+ end
+
+ def parse_json(text, path)
+ JSON.parse(text)
+ rescue JSON::ParserError => error
+ warn("#{path} is not valid JSON: #{error.message}")
+ nil
+ end
+
+ # Matches each document the receipt cites to a file in the bundle.
+ #
+ # The receipt names a key, a version label, and a locale, so those are tried
+ # first, most specific first, and a file whose name merely starts with the key
+ # is the last resort. A document with no matching file is reported as not
+ # checked — never as verified, and never as failed, because "we did not look"
+ # and "we looked and it was wrong" are different answers.
+ def load_documents(receipt, directory)
+ entries = Array(receipt["documents"])
+ return [{}, {}] if directory.nil? || entries.empty?
+
+ unless File.directory?(directory)
+ warn("There is no directory at #{directory}.")
+ return [nil, nil]
+ end
+
+ files = Dir.children(directory).select { |name| File.file?(File.join(directory, name)) }
+ documents = {}
+ sources = {}
+ @ambiguous_documents = {}
+
+ entries.each do |entry|
+ key = entry["key"].to_s
+ version = entry["version"].to_s
+ locale = entry["locale"].to_s
+ identity = [key, version, locale].reject(&:empty?).join("@")
+ next if key.empty? || documents.key?(identity)
+
+ source_name = match_artifact_file(files, key, version, locale, "source")
+ rendered_name = match_artifact_file(files, key, version, locale, "rendered")
+
+ if source_name || rendered_name
+ documents[identity] = {}
+ if source_name
+ documents[identity]["source"] = File.binread(File.join(directory, source_name))
+ sources["#{identity}:source"] = source_name
+ end
+ if rendered_name
+ documents[identity]["rendered"] = File.binread(File.join(directory, rendered_name))
+ sources["#{identity}:rendered"] = rendered_name
+ end
+ next
+ end
+
+ # A single generic file can only mean the representation the receipt
+ # records as offered. When source and rendered digests are identical the
+ # verifier can safely use those same bytes for both; otherwise callers
+ # must use the explicit .source/.rendered names above.
+ name = match_file(files, key, version, locale)
+ next if name.nil?
+
+ documents[identity] = File.binread(File.join(directory, name))
+ sources["#{identity}:rendered"] = name
+ end
+
+ [documents, sources]
+ end
+
+ # Finds the one file that holds a document's bytes, or nothing.
+ #
+ # "Or nothing" is deliberate, and so is the ambiguity check. Two files can
+ # easily reduce to the same name — `terms.md` beside a stray `terms.bak`, or
+ # two locales dropped in the same folder — and a verifier that quietly picked
+ # one would report that a document verified while having read a file the
+ # operator never meant to offer. That is the one failure mode a verification
+ # tool must not have, so an ambiguous match is reported as ambiguous and the
+ # document is left unchecked rather than checked against a guess.
+ def match_file(files, key, version, locale)
+ candidates = ["#{key}-#{version}-#{locale}", "#{key}-#{version}", "#{key}.#{locale}", key]
+
+ candidates.each do |candidate|
+ matches = files.select { |name| basename(name).casecmp?(candidate) }
+ return matches.first if matches.length == 1
+ return ambiguous(key, matches) if matches.length > 1
+ end
+
+ prefixed = files.select { |name| basename(name).downcase.start_with?("#{key.downcase}-") }
+ return prefixed.first if prefixed.length == 1
+ return ambiguous(key, prefixed) if prefixed.length > 1
+
+ nil
+ end
+
+ def match_artifact_file(files, key, version, locale, artifact)
+ candidates = ["#{key}-#{version}-#{locale}", "#{key}-#{version}", "#{key}.#{locale}", key]
+
+ candidates.each do |candidate|
+ pattern = /\A#{Regexp.escape(candidate)}[.-]#{Regexp.escape(artifact)}(?:\..+)?\z/i
+ matches = files.grep(pattern)
+ return matches.first if matches.length == 1
+ return ambiguous("#{key}:#{artifact}", matches) if matches.length > 1
+ end
+
+ nil
+ end
+
+ def ambiguous(key, matches)
+ @ambiguous_documents[key] = matches.sort
+ nil
+ end
+
+ def basename(name) = File.basename(name, File.extname(name))
+
+ def ambiguous_documents = @ambiguous_documents ||= {}
+
+ # --- Reporting ------------------------------------------------------------
+
+ def report(receipt, result, sources)
+ say("Receipt #{receipt["event_id"]} (schema #{result.schema || receipt["schema"]})")
+ say
+
+ Array(result.checks).each { |check| say(" #{SYMBOLS.fetch(status(check), "?")} #{describe(check)}") }
+
+ say
+ sources.each { |key, name| say(" document #{key} read from #{name}") }
+ say(" no document files were supplied, so no document digest was checked") if sources.empty?
+
+ ambiguous_documents.each do |key, matches|
+ say(" ! document #{key} was NOT checked: #{matches.join(" and ")} both match that key.")
+ say(" Leave exactly one file per document so the check reads what you meant.")
+ end
+
+ say
+ Array(result.failures).each { |failure| say(" #{failure}") } if result.failed?
+ say case result.status
+ when "verified" then "VERIFIED — #{summary(result)}."
+ when "incomplete" then "INCOMPLETE — #{summary(result)}; supply the missing artifacts and run again."
+ else "FAILED — #{summary(result)}; see the failing checks above."
+ end
+ say
+ say("A passing digest detects modification of the bytes it covers. It does not establish who")
+ say("produced them, when, or that whoever controlled the original application and its database")
+ say("could not have written both the record and the digest.")
+ end
+
+ def report_as_json(receipt, result, sources)
+ say(
+ JSON.pretty_generate(
+ "event_id" => receipt["event_id"],
+ "schema" => result.schema || receipt["schema"],
+ "success" => result.success?,
+ "status" => result.status,
+ "checks" => Array(result.checks).map do |check|
+ { "name" => value(check, :name), "passed" => status(check), "detail" => value(check, :detail) }
+ end,
+ "failures" => Array(result.failures).map(&:to_s),
+ "documents_read" => sources,
+ "verifier_version" => Clickwrap::VERIFIER_VERSION
+ )
+ )
+ end
+
+ def summary(result)
+ checks = Array(result.checks)
+ passed = checks.count { |check| status(check) == true }
+ skipped = checks.count { |check| status(check).nil? }
+ sentence = "#{passed} of #{checks.length} checks passed"
+
+ skipped.zero? ? sentence : "#{sentence}, #{skipped} could not be run"
+ end
+
+ def describe(check)
+ detail = value(check, :detail)
+ name = value(check, :name)
+
+ detail.to_s.empty? ? name.to_s : "#{name}: #{detail}"
+ end
+
+ # true (checked and passed), false (checked and failed), nil (not checked).
+ def status(check)
+ passed = value(check, :passed)
+ return nil if passed.nil?
+
+ passed == true
+ end
+
+ # Check entries may arrive with symbol or string keys, or as objects that
+ # answer the same names. Reading all three costs four lines and saves the
+ # command from breaking on a detail nobody should have to think about.
+ def value(check, name)
+ return check.public_send(name) if check.respond_to?(name) && !check.is_a?(Hash)
+ return check[name] if check.respond_to?(:key?) && check.key?(name)
+ return check[name.to_s] if check.respond_to?(:key?) && check.key?(name.to_s)
+
+ nil
+ end
+
+ def verifier
+ require "clickwrap/receipt_verifier"
+ Clickwrap::ReceiptVerifier
+ rescue LoadError => error
+ raise Clickwrap::Error, "This build of clickwrap has no receipt verifier available (#{error.message})."
+ end
+
+ def say(text = "") = $stdout.puts(text)
+
+ # Anything that is not the report itself goes to standard error, so
+ # `clickwrap verify ... > result.txt` captures the result and nothing else.
+ def warn(text) = Kernel.warn(text)
+end
+
+exit(ClickwrapCommandLine.run(ARGV))
diff --git a/gemfiles/rails_7.1.gemfile b/gemfiles/rails_7.1.gemfile
new file mode 100644
index 0000000..ff8c9c9
--- /dev/null
+++ b/gemfiles/rails_7.1.gemfile
@@ -0,0 +1,34 @@
+# This file was generated by Appraisal
+
+source "https://rubygems.org"
+
+gem "rake", "~> 13.0"
+gem "rails", "~> 7.1.0"
+gem "kramdown", require: false
+gem "markdown-rails", require: false
+
+group :development do
+ gem "appraisal"
+ gem "rubocop", "~> 1.0", require: false
+ gem "rubocop-minitest", "~> 0.35", require: false
+ gem "rubocop-performance", "~> 1.0", require: false
+end
+
+group :test do
+ gem "minitest", "~> 6.0"
+ gem "minitest-mock"
+ gem "mocha", "~> 2.0"
+ gem "devise", ">= 5.0.4", "< 6", require: false
+ gem "simplecov", "~> 0.22", require: false
+ gem "actionmailer"
+ gem "activejob"
+ gem "mysql2"
+ gem "pg"
+ gem "sqlite3", ">= 2.9.6"
+ gem "bootsnap", require: false
+ gem "propshaft"
+ gem "puma"
+ gem "rdoc", ">= 7.0"
+end
+
+gemspec path: "../"
diff --git a/gemfiles/rails_7.2.gemfile b/gemfiles/rails_7.2.gemfile
new file mode 100644
index 0000000..cc4b484
--- /dev/null
+++ b/gemfiles/rails_7.2.gemfile
@@ -0,0 +1,34 @@
+# This file was generated by Appraisal
+
+source "https://rubygems.org"
+
+gem "rake", "~> 13.0"
+gem "rails", "~> 7.2.0"
+gem "kramdown", require: false
+gem "markdown-rails", require: false
+
+group :development do
+ gem "appraisal"
+ gem "rubocop", "~> 1.0", require: false
+ gem "rubocop-minitest", "~> 0.35", require: false
+ gem "rubocop-performance", "~> 1.0", require: false
+end
+
+group :test do
+ gem "minitest", "~> 6.0"
+ gem "minitest-mock"
+ gem "mocha", "~> 2.0"
+ gem "devise", ">= 5.0.4", "< 6", require: false
+ gem "simplecov", "~> 0.22", require: false
+ gem "actionmailer"
+ gem "activejob"
+ gem "mysql2"
+ gem "pg"
+ gem "sqlite3", ">= 2.9.6"
+ gem "bootsnap", require: false
+ gem "propshaft"
+ gem "puma"
+ gem "rdoc", ">= 7.0"
+end
+
+gemspec path: "../"
diff --git a/gemfiles/rails_8.0.gemfile b/gemfiles/rails_8.0.gemfile
new file mode 100644
index 0000000..3304b8f
--- /dev/null
+++ b/gemfiles/rails_8.0.gemfile
@@ -0,0 +1,34 @@
+# This file was generated by Appraisal
+
+source "https://rubygems.org"
+
+gem "rake", "~> 13.0"
+gem "rails", "~> 8.0.0"
+gem "kramdown", require: false
+gem "markdown-rails", require: false
+
+group :development do
+ gem "appraisal"
+ gem "rubocop", "~> 1.0", require: false
+ gem "rubocop-minitest", "~> 0.35", require: false
+ gem "rubocop-performance", "~> 1.0", require: false
+end
+
+group :test do
+ gem "minitest", "~> 6.0"
+ gem "minitest-mock"
+ gem "mocha", "~> 2.0"
+ gem "devise", ">= 5.0.4", "< 6", require: false
+ gem "simplecov", "~> 0.22", require: false
+ gem "actionmailer"
+ gem "activejob"
+ gem "mysql2"
+ gem "pg"
+ gem "sqlite3", ">= 2.9.6"
+ gem "bootsnap", require: false
+ gem "propshaft"
+ gem "puma"
+ gem "rdoc", ">= 7.0"
+end
+
+gemspec path: "../"
diff --git a/gemfiles/rails_8.1.gemfile b/gemfiles/rails_8.1.gemfile
new file mode 100644
index 0000000..3a5d412
--- /dev/null
+++ b/gemfiles/rails_8.1.gemfile
@@ -0,0 +1,34 @@
+# This file was generated by Appraisal
+
+source "https://rubygems.org"
+
+gem "rake", "~> 13.0"
+gem "rails", "~> 8.1.0"
+gem "kramdown", require: false
+gem "markdown-rails", require: false
+
+group :development do
+ gem "appraisal"
+ gem "rubocop", "~> 1.0", require: false
+ gem "rubocop-minitest", "~> 0.35", require: false
+ gem "rubocop-performance", "~> 1.0", require: false
+end
+
+group :test do
+ gem "minitest", "~> 6.0"
+ gem "minitest-mock"
+ gem "mocha", "~> 2.0"
+ gem "devise", ">= 5.0.4", "< 6", require: false
+ gem "simplecov", "~> 0.22", require: false
+ gem "actionmailer"
+ gem "activejob"
+ gem "mysql2"
+ gem "pg"
+ gem "sqlite3", ">= 2.9.6"
+ gem "bootsnap", require: false
+ gem "propshaft"
+ gem "puma"
+ gem "rdoc", ">= 7.0"
+end
+
+gemspec path: "../"
diff --git a/guides/README.md b/guides/README.md
new file mode 100644
index 0000000..7d0d40d
--- /dev/null
+++ b/guides/README.md
@@ -0,0 +1,30 @@
+# Clickwrap guides
+
+The [README](../README.md) is the tour: it gets you from install to a verified receipt and
+shows each capability briefly. These guides are the depth behind the parts that are easy to
+get subtly wrong, and they assume you have already read the README section they expand on.
+
+| Guide | Read it when |
+|---|---|
+| [Integrating](integrating.md) | When you are wiring the gem into a real application — or pointing an AI agent at the job. The battle-tested playbook from a full production migration: install order, real legal content, test setup, Devise bridges, custom surfaces, protecting a money path, importing history, and the dual-write rollout doctrine. |
+| [Request evidence](request-evidence.md) | Before you enable IP address, browser user-agent, or any IP-geolocation field. It is the data dictionary: one row per field, where it comes from, what it does not establish, who can read it, and what happens to it when it is deleted. |
+| [Receipts and verification](receipts-and-verification.md) | When you need to hand a receipt to somebody outside your application, or explain exactly what a green verification result covers. Also the canonicalization profile, if you are writing a verifier of your own. |
+| [Retention and legal holds](retention-and-legal-holds.md) | When your retention periods come from a real obligation rather than a round number, when a duration cannot express the schedule, or before the first time you run a disposition against production data. |
+| [Integrity](integrity.md) | When someone asks how strong the audit trail is, or which of the five tiers you are actually on. Includes the threat model as a list of "what happens if" scenarios. |
+| [Consent and lifecycle](consent-and-lifecycle.md) | When you are choosing between `agree_to`, `acknowledge`, and `consent_to` for a specific screen, or when you need the full state and action table for a kind. |
+| [Migrating](migrating.md) | When you have an `accepted_terms_at` column or a FinePrint installation and you want the history without inventing the parts of it nobody recorded. |
+| [Accessibility](accessibility.md) | Before your accessibility review, so you know exactly which line is the reference views' responsibility and which is your page's. |
+| [Naming](naming.md) | Before you propose a public method, option, configuration setting, or receipt field — or before you review a pull request that adds one. |
+| [Organizations](organizations.md) | When a human user accepts or authorizes something on behalf of an organization. Separates actor, represented party, tenant, subject, membership evidence, and the legal-authority boundary. |
+
+Two things hold across all of them.
+
+**Clickwrap is evidence mechanics.** It records what was offered, what was answered, and what
+committed alongside it. Your application and its counsel own the words, the lawful basis, the
+retention periods, the identity questions, and every legal conclusion. Nothing in these guides
+is advice about any of that.
+
+**Every external claim here carries an exact URL and a source class.** Law, court decisions,
+regulator guidance, technical standards, vendor documentation, and this project's own design
+inferences are labeled separately, because they carry very different weight. Source-code
+citations are pinned to commit `a1ffe9b` of this repository rather than to a moving branch.
diff --git a/guides/accessibility.md b/guides/accessibility.md
new file mode 100644
index 0000000..de184f9
--- /dev/null
+++ b/guides/accessibility.md
@@ -0,0 +1,249 @@
+# Accessibility: what the reference views do, and what stays yours
+
+Clickwrap ships tested reference views. They are a good starting point and they are one
+fragment of one page. **Nothing in this gem certifies your application under
+[WCAG 2.2](https://www.w3.org/TR/WCAG22/) *(technical standard)* or any other accessibility
+standard**, and no library that sees a single partial could. Accessibility applies to the whole
+experience: placement, contrast, clutter, reading order, focus management across the page,
+error recovery, and whether the surrounding design lets somebody find the control at all.
+
+What follows is the exact division of responsibility, so your review can spend its time on the
+part that is actually yours.
+
+---
+
+## What the reference views do
+
+All of this is in `app/views/clickwrap/shared/_fields.html.erb`,
+`_statement.html.erb`, and `_error_summary.html.erb`, and is exercised by
+the gem's own suite.
+
+### Labels and programmatic names
+
+Every control has one label, tied together by ID, and the label carries the words:
+
+```erb
+<%= check_box_tag combined.control_name, "1", false, id: combined.control_id, ... %>
+<%= label_tag combined.control_id, clickwrap_combined_sentence(combined) %>
+```
+
+The label **is** the sentence — including the document links, which sit inside it. Pressing the
+words toggles the control, and assistive technology announces the sentence and the box together.
+There is no `aria-label` standing in for a visible label, and no placeholder doing a label's job.
+
+For an explicit yes/no decision, the group is a real `