Skip to content

Implement clickwrap 0.1.0 - #1

Draft
rameerez wants to merge 75 commits into
mainfrom
implementation
Draft

Implement clickwrap 0.1.0#1
rameerez wants to merge 75 commits into
mainfrom
implementation

Conversation

@rameerez

Copy link
Copy Markdown
Owner

The full implementation of the gem the 0.0.0 name-reservation README promised: immutable versioned documents, server-owned compiled policies, signed presentation manifests, atomic capture, append-only lifecycle events, canonical RFC 8785 receipts with a standalone verifier, retention/disposition/legal holds, request-evidence annex, importers, and the generators.

Opened primarily so the CI matrix (SQLite/PostgreSQL/MySQL × Ruby versions) actually runs against this branch — until now every SHA consumed downstream (CarHey PR carheyapp/carhey#237 dogfoods the gem) was only verified locally. Review history and the audit trail live in docs/reviews/ (git-ignored research corpus) and in the CHANGELOG's Unreleased section.

Not for merge until the release checklist in the repo's review records is closed (remote CI, downstream proof, unfamiliar-developer pass, legal/privacy review, publication gates).

🤖 Generated with Claude Code

https://claude.ai/code/session_01KVpE7vAp6CvTJVEvsWdPvu

rameerez and others added 30 commits August 15, 2026 06:37
Builds the gem the README describes: immutable versioned documents, server-owned
policies over six kinds, signed presentation manifests, append-only events, and
receipts anyone can verify without this application.

The load-bearing pieces:

- RFC 8785 canonical JSON, verified against the spec's own worked example, so a
  receipt's bytes are reproducible by a verifier in another language years from
  now.
- capture_and! joins the caller's transaction. Evidence and the protected action
  commit together or neither does, proven by injecting a failure on each side.
- The presentation manifest binds render to submit. A deploy between GET and
  POST cannot make the server record a version nobody was offered.
- The presentation nonce is the idempotency key, and a unique index settles the
  race, so a double-click cannot produce two debits.
- Optional IP address, browser user-agent, and IP-geolocation evidence is off
  until a policy names the field, encrypted, and kept in a separate annex so a
  lawful deletion never breaks the agreement it accompanied.
- Retention, legal holds, and two-step disposition; event-based "later of" rules
  that report unresolved rather than guessing a date.

Refusals explain themselves: consent with no withdrawal route, a one-time
authorization with no expiry, a policy with no retention class, and a version
label naming a moving target are all boot failures with a sentence saying what
to do instead.

156 tests, 0 failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fills in everything around the core: the form-builder helper and ejectable
reference views, the mounted capture/receipt/withdrawal screens, the six
generators, retention and privacy tooling, the rake tasks, and the standalone
verifier CLI.

Fixes found by writing the tests:

- Chaining wrote the head before the digest existed, so every previous_event_digest
  was nil. Reserving the position and recording the digest are now two phases.
- The request-evidence binding digest was written after the event digest was
  computed, so any event with request evidence failed its own verification.
- The event digest covered actor_type, which dependent: :nullify blanks — so
  deleting an account made that person's evidence look tampered with. The digest
  now covers the stable actor reference, which is what outlives the row.
- Verification trusted a document version's digest column instead of re-hashing
  its bytes, so a row edited in place passed.
- An answer for a statement the presentation never offered was silently dropped
  rather than refused.
- The verifier CLI resolved an ambiguous document filename by picking one. It
  now reports the ambiguity and leaves the document unchecked, because reading
  the wrong file and calling it verified is the one thing a verifier must not do.
- destroy on an event was aborted by dependent: :restrict_with_error before the
  append-only guard ran, so it failed silently instead of raising.

Golden receipt fixtures are committed and never regenerated: the current
verifier runs against them every build, which is what makes "we still verify
receipts exported years ago" a fact rather than an intention.

228 tests, 0 failures. Rubocop clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds the tests that were missing around the edges — canonicalization against
RFC 8785's own worked example, configuration defaults and refusals, document
publishing and digest verification, retention, privacy, doctor, integrity,
generators, the web layer, the rake tasks, and the CLI.

Fixes:

- The requires_clickwrap dead-end check ran during eager-load, before Rails
  draws routes, so it reported "the engine is not mounted" to applications that
  mount it perfectly well. Gates now register at declaration and are checked
  from after_routes_loaded, which is the one moment the answer is knowable.
- Mount detection walked route objects, whose wrapping has changed shape across
  Rails versions. It now asks Rails' own mounted_helpers first.
- A per-document `resolver:` was treated as a storage backend, so reading the
  document back went looking for a global resolver that was never configured.
  A resolver is a SOURCE, like `from:` and `content:` — called once at publish,
  then the frozen snapshot is the evidence.
- The CLI verifier read `terms.bak` when asked for `terms`. An ambiguous
  filename now reports the ambiguity and leaves the document unchecked.

Rubocop's autocorrect collapsed Configuration's section-grouped attr_readers
into one 1048-character line; Style/AccessorGrouping is now excluded there with
the reason, since those groups mirror the generated initializer.

364 tests, 0 failures. Rubocop clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Writing the guides against the real source turned up places where the two had
drifted apart. Each is fixed in whichever direction was actually right.

- `encrypt_recorded_ip_geolocation` defaulted to true and encrypted nothing.
  Every geolocation VALUE column is now encrypted at rest, including the country
  code — lower precision than a coordinate, still personal data once attached to
  an identified actor and an event. Provenance columns stay readable on purpose:
  they say how certain a value is rather than what it is, and doctor and the
  privacy inventory read them.
- Coordinates move from decimal to string columns. A receipt serializes them as
  strings anyway, so a decimal put a rounding step between what the provider said
  and what the evidence shows — and a string column can hold ciphertext.
- Added `event.consent_was_withdrawn?` and its siblings, which the README and the
  generated initializer both already used.
- Added the `clickwrap:import:fine_print` and `:plan` tasks the README documents.
- The README's receipt example was stale: it showed field names the code stopped
  emitting and an `integrity.verified` key that never existed. It now shows the
  real body, including both digests and what each one can answer.

Also fixes a portability bug the PostgreSQL lane caught: `current_version`
ordered by a nullable `effective_at`, and PostgreSQL sorts NULLs first in a
descending order where SQLite sorts them last — so two databases could disagree
about which document a person was shown. Publishing now always writes an
effective time.

366 tests green on SQLite and on PostgreSQL, where the concurrent-writer test
actually runs instead of skipping. Rubocop clean, gem builds, zeitwerk clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
MySQL could not run the schema at all: the unique index on
clickwrap_statement_states covers five string columns, which exceeds MySQL's
3072-byte index limit under utf8mb4 even at a reduced length. That index is what
stops a double submit from becoming two debits, so dropping it was not an
option and neither was claiming MySQL support without it.

The identity is now hashed into one fixed-width column carrying the unique
index, with an ordinary lookup index over the readable columns beside it. Same
guarantee, on every supported database.

The FinePrint importer test creates and drops real fine_print_* tables, and on
MySQL a DDL statement implicitly commits the surrounding transaction and
destroys the savepoint the harness relies on. It moves to its own class with
transactional fixtures off, which is the house precedent for a test that issues
DDL, and cleans up after itself.

Also gitignores the empty test/dummy/db/schema.rb a `db:test:prepare` roundtrip
can leave behind — while it exists, maintain_test_schema! loads it over the real
schema and the whole suite dies with "Migrations are pending".

369 tests, 0 failures on all three databases. SQLite skips only the
concurrent-writer test, which PostgreSQL and MySQL run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Clickwrap.withdraw! raised NotWithdrawableError for both, which made the
withdrawal screen's AlreadyWithdrawnError rescue unreachable and showed a person
pressing the button twice a 422 for something that had already worked.

They are different situations. Pressing withdraw again is not an error worth
showing anyone — the purpose is withdrawn either way, which is what they asked
for — so it redirects with a benign notice and appends nothing. Withdrawing
something that was never granted still says so, and its message notes that
leaving an optional control unselected creates no grant, which is the usual
reason there is nothing to find.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Installing the gem into a freshly generated Rails app and following the printed
steps turned up three things the test suite structurally could not see.

The app would not boot. The Active Record encryption key check ran from the
engine's to_prepare, so adding this gem to an application stopped it starting
before the developer had any chance to generate a key — and most applications
record no request evidence at all, so the check was failing installations that
were never going to encrypt anything. It now runs when something is actually
about to be encrypted, with the same sentence, and clickwrap:doctor reports
missing keys before that.

Publishing a document with an em dash in it raised an encoding error. Documents
are read as binary so the digest covers the exact bytes, and a text column holds
text; the two are now reconciled explicitly, and a document that is not valid
UTF-8 is refused with a pointer to a storage backend that keeps bytes as bytes,
rather than being silently corrupted into something whose digest can never
verify again. The dummy's fixtures were pure ASCII; the generated placeholders
are not, and neither is real legal text.

Clickwrap.system_actor raised NameError. AnonymousActor and SystemActor shared
a file, which resolves under eager loading and fails in an ordinary development
app. They are now one constant per file, and a boot test asserts each resolves
from its own path.

The five-minute path now runs end to end on a fresh app: install, publish,
capture, export, and verify the receipt with the standalone CLI and no
application present.

372 tests, 0 failures on SQLite, PostgreSQL, and MySQL. Rubocop clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The CarHey integration (Rails 8.1, PostGIS, uuid keys, Devise, Hotwire
Native, Spanish-first, and a legacy acceptance ledger) is the first of the
proof integrations, and it falsified several comfortable assumptions within
minutes. Everything here is what reality demanded:

- Require the framework-integration modules at boot. A host whose other gems
  load Action View or Action Controller first fires the engine's on_load
  hooks immediately, before the autoloader can serve those constants; the
  dummy app can never reproduce that.
- Recognize PostGIS as PostgreSQL (jsonb) and Trilogy as MySQL in the install
  migration's adaptive column types.
- Add the :markdown document renderer: real HTML through whichever Markdown
  library the host already bundles (commonmarker, redcarpet, or kramdown —
  no new dependency), leading YAML front matter stripped from the rendered
  representation only, engine provenance recorded in the receipt, safe-list
  sanitized. The generated initializer now selects :safe_text explicitly
  instead of writing nil, which silently disabled rendering.
- Ship the Spanish locale.
- Refuse gracefully in the Devise adapter: a missing/stale presentation or a
  declined required statement re-renders the form with the gem's localized
  sentences — inline beside the control and on :base — never a raw 500 or a
  developer-facing message in front of a person.
- Add clickwrap_submission_params_from(response): host integration tests
  read the signed presentation token and its controls back off the rendered
  page, the way a browser does, because a session-bound token cannot be
  fabricated.
- Make imported legacy evidence answer the everyday predicates. An import
  now projects into current state exactly as a capture does and appears in
  the actor's receipts collection, so a migrated application keeps answering
  "did this person agree?" the way it answered the day before the migration
  — without inventing evidence: imported_provider attribution, unknowns
  named in the receipt, require_current_version still re-prompts when the
  documents move on. The previous semantics would have turned every
  migration into a mass forced re-acceptance. 0.1.0 was never published, so
  no installed application observes a change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KVpE7vAp6CvTJVEvsWdPvu
Migrating CarHey's withdrawal funnel (three custom forms, none of them a
plain form.clickwrap) surfaced the gap between the one-line happy path and
raw Clickwrap.present primitives. Every custom surface hand-wrote the same
three contracts, and each one fails silently when mistyped:

- View helpers, available in every view: clickwrap_presentation_token_field
  (the envelope name is never hand-typed again),
  clickwrap_statement_check_box / clickwrap_statement_radio_button (declared
  names and ids, initially unchecked, required mirrored), and
  clickwrap_submit_button (the call to action worded by the signed manifest
  itself, so recorded words and pressed words cannot drift).
- Clickwrap.verify(..., require_current_revision: true): opt-in revision
  currency — evidence recorded under a superseded policy revision fails with
  :stale_policy_revision, replacing the revision comparison CarHey's
  WithdrawalAuthorization had to hand-roll.
- Predicates on verification results, generated from the vocabulary
  (result.subject_fingerprint_mismatch?, result.no_evidence?, …) — branching
  that reads aloud and turns a typo into NoMethodError instead of a silently
  false comparison.
- clickwrap_params_from(path, answers:) in TestHelpers — the one-line
  integration-test pattern hosts were writing themselves.
- The unknown-document boot error now teaches `document: nil` for
  operational-fact statements.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KVpE7vAp6CvTJVEvsWdPvu
Second DX round from the CarHey migration, driven by the glue that was
still left in the host after round one:

- Clickwrap::CaptureRefused: every refusal a person can cause from a
  form (stale/missing presentation, unparseable submission, declined
  required statement) now shares one superclass carrying a localized
  #user_facing_message, so five hand-written rescue sites collapse to
  one `rescue Clickwrap::CaptureRefused`. Infrastructure failures stay
  outside the family and stay loud.
- has_clickwrap_evidence + `rails generate clickwrap:link TABLE`: the
  row-level link between a domain record and the capture that
  authorized it becomes one macro and one generated migration
  (clickwrap_event_id ULID string, indexed, nullable, no FK — each
  deliberate and documented in the migration itself).
- Result#recorded_after?: evidence ordering without hosts comparing
  event ids by hand.
- Installer checklist now teaches the test setup (publish per parallel
  worker and per process, clickwrap_params_from) because tokens cannot
  be fabricated by tests — that is the point, and it should be learned
  from the checklist, not from a red suite.
- guides/integrating.md: the full battle-tested playbook from the
  production migration — install order, real legal content, test
  patterns, Devise dual-write bridges, custom surfaces, money-path
  protection, legacy import, request evidence, rollout doctrine.
  README updated to surface the new APIs and link the guide.

556 runs green; docs fences compile (the documentation test caught two
that did not).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KVpE7vAp6CvTJVEvsWdPvu
A lead-capture or newsletter form finds-or-creates its row by typed
email. The registration primitive refused the "found" half: a persisted
prospective actor raised "Registration evidence can only be bound while
the account is new", so a returning visitor could not re-answer the
form at all (found while migrating a production lead-magnet funnel).

- register! (and Registration.perform / register_with_clickwrap) gains
  the explicit `actor_may_already_exist: true` opt-in for the upsert
  shape. The default still refuses a persisted prospective actor — that
  is usually a bug (the host meant capture! with `actor:`) — and the
  refusal now teaches both resolutions.
- Attribution stays honest per act: creating the row records
  `account_registration` as before; matching an existing row records
  the new `public_form` vocabulary word — an unauthenticated submission
  named this actor, and no account was created by it. The events
  table's attribution CHECK constraint gains the word in the install
  template and the dummy migration (unreleased schema, no installed
  application observes a change).
- guides/integrating.md documents the shape next to the Devise section;
  three new proof tests cover refusal-by-default, the found case, and
  the created case through one call site.

559 runs green, RuboCop clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KVpE7vAp6CvTJVEvsWdPvu
Recovered work-in-progress from the overnight hardening session
(authored there, verified and committed here): answer_for, answered?,
granted?, and declined? on PendingReceipt read the validated,
server-bound event being committed, so protected domain logic stops
parsing browser params a second time. An optional control left
unselected reads as silence (nil/false) — never as a grant or a
decline — while a statement key the policy never declared raises, so
a typo cannot silently disable a feature at a consequential boundary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KVpE7vAp6CvTJVEvsWdPvu
The durable-sequence test froze the clock at 2026-08-16 12:00 UTC —
written before noon UTC, it passed; after noon, the frozen moment
predates the suite's boot-time document publication and every capture
refuses with DocumentNotPublishedError. Freeze at the present instant
instead: two captures on one frozen clock still share a timestamp,
which is the property the test exists to isolate (ordering must come
from the recording sequence, not from time or ULID comparison).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KVpE7vAp6CvTJVEvsWdPvu
document_link_html_options_with chose HOW immutable document links
open, but was called context-free — so a host whose native app and web
need different behavior (the canonical case: Hotwire Native must keep
document links same-window for its screen rules to route them as
sheets, while the web wants a new tab) had no way to ask
`hotwire_native_app?`. The helper now instance_execs the hook in the
rendering view, where every per-request helper already lives. Existing
context-free hooks behave identically; the generated initializer shows
the native-aware shape.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KVpE7vAp6CvTJVEvsWdPvu
rameerez and others added 30 commits August 17, 2026 02:56
…or query

Two defects in one line of `ReceiptsController#index`.

The pagination one is the serious half: taking PER_PAGE rows and
filtering them afterwards renders "you have no receipts" whenever the
viewer's newest fifty events happen to be ones the host's callback will
not show them — while readable receipts sit at row fifty-one. On a
screen whose entire job is "show me exactly what the application
recorded", telling someone their evidence is gone by accident is the
worst available failure. Authorization now happens first, in bounded
batches, and paging happens over what survived it.

The cost one is ordinary: the conventional callback in the README
compares `controller.current_user == receipt.actor`, which loads the
polymorphic actor once per row. Eager-loading it makes a full page three
queries instead of fifty-two.

`page_size`, `batch_size`, and `authorization_scan_limit` are readers
rather than bare constants, so a host that ejects this controller can
page it differently by overriding one method — and the tests can set a
tiny page without fabricating fifty-one captures. The scan limit is the
new bound the Ruby-side filter needs: the database cannot apply a host
callback, so something has to say how far this screen will look.

Two tests: one proves a readable receipt past the page limit is still
shown, one pins the steady-state query count at three and asserts it
does not move when the history quadruples. That second test counts
query-cache hits deliberately — an N+1 whose rows all resolve to the
same actor is served from the cache and would otherwise measure as free
here while costing a real round trip each in production.

706 runs, 0 failures, 5 skips; RuboCop clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every statement-document pair cost its own lookup: one query for the
document (two when a tenant scope missed), then one for its current
version. A policy naming the same document three times paid three times,
which is the part that gives it away — the result cannot differ, because
documents are immutable and published and the presentation is one
instant.

The presenter now resolves every key the policy references up front: one
query for the documents, one for every version those documents could
offer, ordered exactly the way `Document#current_version` orders a single
document's versions so the first row per document is the row that method
would have returned. The tenant's own document still wins over the shared
one, and when there is no tenant the two scopes are the same query, so
only one is issued.

The test counts only document-table queries, so it pins the number this
change is about rather than every unrelated query a render happens to
make, and asserts that two distinct documents and one document named
three times cost the same. `count_queries` moves to the test helper,
where the receipts-list test already wanted it.

707 runs, 0 failures, 5 skips; RuboCop clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The installer spends about sixty lines, a `--actor-class` option, and a
seven-line yellow warning on this setting, on the grounds that "a wrong
guess attributes evidence to the wrong kind of record for years". That
was not true. The setting fed exactly one error string, and
`Configuration#actor_class` — constantization, cache invalidation and
all — had no production caller anywhere in the gem.

Rather than delete the warning, this makes it accurate. Capture now
asserts that the record it is about to write as the actor is an instance
of the configured class, and refuses with a sentence that names the
setting, names both ways to fix it, and points an organization at
`acting_for:` — because passing the represented party as the actor is
the mistake this check will actually catch most often.

Deliberately narrow. A system actor, an anonymous actor, and a literal
string reference to an actor in another system are their own kinds, each
says so in the receipt, and none of them is a wrong guess about the
host's model. An actor class name that does not resolve now says that,
too, instead of surfacing as a bare NameError from somewhere further in.

710 runs, 0 failures, 5 skips; RuboCop clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`renew!`, `correct_declaration!`, and `change_consent_scope!` had zero
tests, zero README, and zero consumers — while the README's "six verbs"
table showcases exactly the lifecycles they implement. They are the
product's conceptual spine, so they get tested rather than deleted.

Each verb now has a behavioral test: the event is appended with the
right type and links, the earlier event still says exactly what it said
and still digest-verifies, the projection points at the new act, and the
new receipt verifies on its own. Plus the refusals that give each verb
its meaning — an agreement cannot be corrected (that is what a new
version is for), a statement with no validity period cannot be renewed,
and only consent has a changeable scope.

Writing those tests found the reason nobody had used them: all three
capture through a real presentation, so every one of them needs a
`submission:` exactly as the original statement did. That is the right
design — a correction, a renewal, and a rescope are new statements by
the same person, not administrative flags flipped behind their back —
but nothing said so anywhere, and the first thing anyone reaching for
these verbs hit was `PresentationInvalid`. The README now shows all
three with their submission and one sentence each about what they mean.

Two projection facts the tests pin, because both could plausibly have
gone the other way: a corrected declaration is still ACTIVE (the person
declared something, just something else), and a rescoped consent is
still ACTIVE (narrowing a permission is not withdrawing it).

716 runs, 0 failures, 5 skips; RuboCop clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`TransactionUnavailable` and `UnknownDocumentError` are raised nowhere
in the gem. A public error class that cannot occur is worse than absent:
it invites a host to write a `rescue` that will never fire and to
believe a failure mode is being handled.

The opposite problem is the two error classes with real raise paths and
no test at all, both security-relevant:

* `RemediationNotAuthorized` is what stops a `subject_with:` gate from
  minting a signed remediation token for somebody else's record. The
  integration tests only ever see its 404, and a 404 can come from
  several places — so the raise itself is now pinned, for both the
  subject and the represented-party callback.
* `RequestEvidenceUnavailable` is a policy deciding that evidence it
  cannot get is worse than no capture at all. The test proves the whole
  capture is refused and no event survives, that the message names the
  policy, the category and the requirement, and — by contrast — that the
  same missing value on a policy that does NOT fail closed becomes an
  explicit `unavailable` state on the receipt instead.

720 runs, 0 failures, 5 skips; RuboCop clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The examples, the guides, the CHANGELOG, and the whole dummy fixture
graph were written in one real business's vocabulary. A gem's public
documentation should not name anybody's company, and an example only
teaches when the reader can see themselves in it.

The pedagogy is preserved exactly — same policies, same statements, same
lifecycles, same subject-fingerprint lesson — in a neutral marketplace
domain:

    :driver_declaration       → :contractor_declaration
    :non_professional_driver  → :independent_contractor
    :ride_exclusivity         → :coverage_exclusivity
    covered_rides_fingerprint → covered_orders_fingerprint
    covered_ride_ids          → covered_order_ids
    "covered-rides-v1"        → "covered-orders-v1"

with the statements reworded to match ("I provide these services as an
independent contractor, not as an employee"; "I declare that these
orders have not been claimed for any other payout"). Three CHANGELOG
entries now credit "the first production host application" rather than
naming it. The dummy's declaration fixture moves with its key.

One deliberate exception, now explained in the golden-receipt test's own
header: `test/fixtures/receipts/withdrawal_authorization.clickwrap.receipt.v1.json`
keeps the old vocabulary. Golden receipts are captured artifacts whose
digests are computed over those exact bytes — rewriting the words means
recomputing the digests, which is the same thing as saying they were
never frozen. Nothing in them names a business; they carry example
statement keys, and that is what a receipt from that moment contained.

720 runs, 0 failures, 5 skips; RuboCop clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…warders

Two API-coherence decisions, both pre-1.0 and both about a caller being
able to see what a method takes.

`register_with_clickwrap` took `user:` AND `prospective_actor:` for the
same record, with undocumented precedence between them. `user:` is the
spelling every signup controller already uses, so it is the one that
stays; the docstring says to pass your actor there whatever its class is
named. `prospective_actor:` is refused explicitly rather than deleted
quietly — it would otherwise ride the `**` forward straight into
Registration.perform and go on working invisibly, which is the worst
possible way to remove an argument.

(`prospective_actor:` remains on `Clickwrap.present`, where it is a
different concept and the only honest name: at render time there is no
persisted actor at all.)

The nine `Clickwrap.*` lifecycle and import methods forwarded a bare
`**` to their targets. That compiles fine and reads fine, and then an
editor shows `**` where the argument list should be, a typo'd keyword
travels one method deeper before failing, and the gem's public API is
documented only in the private method behind it. Each now names the
keywords it accepts. Where a target declared `because:` optional but
raised without one, the public forwarder makes it required, so the
signature says what the method actually needs.

A boot test pins it: no public lifecycle or import entry point may
declare a `:keyrest` parameter.

722 runs, 0 failures, 5 skips; RuboCop clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`Clickwrap::TestHelpers#capture_clickwrap` raised on a refusal.
`Clickwrap::ControllerHelpers#capture_clickwrap` returned false. One
name, two opposite answers to "what happens when this is refused", and
nothing stopping both modules from ending up on the same object — at
which point the failure mode of a helper depends on which module won.

The test helper is renamed to `submit_clickwrap` (and
`submit_clickwrap_and`), because it is a different act as well as a
different contract: it SUBMITS a presentation it just built, while the
controller helper CAPTURES a submission a person actually sent. Both
docstrings now say which is which, and why each takes the failure mode
it does — a failed capture in a test is a failed test; a refused
submission in a controller is a person to answer.

Every internal call site moves with it, and the README and integrating
guide teach the new verb beside the distinction.

722 runs, 0 failures, 5 skips; RuboCop clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`form.clickwrap_fields` asked for `submit_button_text:` and then gave the
host nothing to render it from. A design system with its own `<button>`
markup therefore typed the same string twice — once for the manifest,
once for the page — and the only thing standing between those two copies
was a comment telling every host not to let them drift. The `form.submit`
check does not help here: a raw `<button>` bypasses it entirely.

`clickwrap_fields` now takes a block and yields the whole
Presenter::Result, so the button reads the wording instead of repeating
it, and the block's markup renders inside the same wrapper, after the
controls and their document links (a link that appears below the button
has already been skipped). With a block given, the `form.submit`
comparison is not armed: there is no second copy left to compare, and a
form that never calls `form.submit` should not be waiting for one.

`submit:` and `submit_button_text:` stay as they are, and are now
documented as the intentional pair they are: one binds the words and
renders the button, the other binds the words and leaves the action to
you. The integrating guide states all three ways to render that action
and what each one can and cannot check.

725 runs, 0 failures, 5 skips; RuboCop clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
One application often needs both native answers. The auth sheet has to
escape the WebView, or a same-host document link pops the sheet and takes
the half-filled signup form with it; a document sheet inside a signed-in
funnel is routed by the app's own path configuration. A single symbol
cannot say that, which is why the one production-shaped host declined
this seam and kept hand-rolling the per-request hook.

`open_in:` now takes a callable, exactly as `canonical_host:` already
did. Three details are load-bearing:

* It is resolved with the CONTROLLER, and both halves of a document link
  resolve from the same one — the href when the presentation is signed,
  the attributes when the link is rendered. An absolutized href carrying
  same-screen attributes would leave the WebView with nothing to carry
  it there.
* A callable `open_in:` requires `canonical_host:`, because nothing at
  boot can rule out its answering `:external_browser`.
* A callable that answers anything else is refused when it answers, with
  its own answer quoted. There is no third way for a document link to
  open, and inventing a fallback would silently pick one.

729 runs, 0 failures, 5 skips; RuboCop clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The statement-option refusal carried a table mapping `documents:`,
`assertion:`, and `link_labels:` to their current spellings, so an
unknown option could be answered with "that was renamed".

Those renames happened before this gem was ever released. No application
anywhere wrote the old names, so the table answers a question nobody can
ask — and, worse, it invents a history: a developer reading that error
concludes there was a version where those options worked, and goes
looking for an upgrade note that does not exist.

The full list of supported options was already in the same sentence, and
it is the complete answer.

729 runs, 0 failures, 5 skips; RuboCop clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Seven of the seventeen tables `clickwrap:install` wrote — 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. On a default
install, not one of them could ever receive a row. That is 43% of the
emitted columns describing capabilities and personal-data categories the
application does not have, in the one artifact a reviewer is most likely
to read as a statement of what a system collects.

The migration is now one core file plus one file per capability, each
behind its own flag, following the `clickwrap:hardening` precedent:

    --with-persisted-presentations   --with-integrity
    --with-request-evidence          --with-retention-ops
                                     --with-external-actions

Re-running the generator later with a flag adds that migration then, so
a capability is always one command away. `--with-request-evidence` is
implied whenever the run turns a request-evidence field on: recording IP
addresses into a table you never created is not a schema choice, and the
operator already answered the question that matters. The post-install
message names every tier that was NOT emitted, so the omission is a
decision rather than a surprise.

The trade creates exactly one new failure mode — turning a capability on
and forgetting its migration — and it is closed three ways, each naming
the exact command:

* At boot, from the engine's to_prepare.
* By `bin/rails clickwrap:doctor`, which now separates "configured and
  missing" (a problem) from "not installed and nothing needs it" (a fact
  about this installation, so an operator at 03:00 does not go looking
  for a table that was never meant to be there).
* At the entry points no configuration announces —
  `authorize_external_action!`, `place_on_legal_hold!`, and disposition
  planning are called, not declared, so nothing could see them coming.

Two subtleties the tests pin. The boot check stays quiet while
migrations are pending and before Clickwrap's own core tables exist:
`rails db:migrate` boots the application before running the migration
that would satisfy the check, so raising there would make the fix
unrunnable. And "is this table here?" has three answers, not two —
true, false, and "there is no connection to ask" — because folding the
third into false would report every booting application as broken.

`clickwrap_events.presentation_id` keeps its column in the core
migration and gains its foreign key with the table it points at.

The drift tests now run per tier: same columns, tables, indexes, and
foreign keys across the whole set, each dummy migration an exact
rendering of its template, plus a new test that each tier emits exactly
the tables its flag promises. The scratch-database test migrates all six
in order.

742 runs, 0 failures, 5 skips; RuboCop clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The template carried about thirty live lines that assigned the gem's own
default back to itself: `store_document_contents_in = :database`,
`presentation_valid_for = 2.hours`, `digest_canonical_receipts_with =
:sha256`, three `encrypt_recorded_* = true`, a pile of `= nil`. Every
one of them is a line to maintain in two places forever, and together
they made the file unreadable in the way that matters: a reader could
not tell which lines somebody had chosen and which were the gem
describing itself.

Live lines are now only what this installer actually decided or
detected. Everything else appears commented, with its default value,
under prose explaining the consequence — the shape the one production
host arrived at independently. A default install goes from ~30 live
settings to three: the receipt-access rule the installer grants
(self-service, and nothing wider than that), the error reporter, and
the detected document renderer when there is one. Detected identity
settings that match the gem's defaults are shown commented rather than
written, because detecting `User` and `current_user` and then writing
them changes nothing.

One deliberate exception, stated in the file where the rule is: the
eleven `record_*_by_default` lines stay live even when every answer is
`false`. Each is an answer to a question this installer asked out loud,
and "we decided not to collect this" is a decision worth reading rather
than inferring from a file that does not mention it.

The generator tests move with it: a new `assert_no_live_setting` helper
pins the rule itself — a live line means somebody decided something —
alongside the existing assertions that each default is still SHOWN,
commented, with its value.

config/clickwrap.rb needs no equivalent pass: the gem ships no default
documents, policies, or retention classes, so every live line in that
template is already a decision.

742 runs, 0 failures, 5 skips; RuboCop clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The generator carried nine `class_option`s for IP-geolocation fields,
spelled in the plural, plus a table translating those plurals back into
the singular names the configuration uses. Twenty-odd lines of
command-line surface, and a second vocabulary to keep in step with the
first, for what is a list.

`--record-ip-geolocation-fields=city,country` replaces all of it, using
the configuration's own field names so there is one vocabulary. It takes
either shell convention (`=city,country` and space-separated), because
getting that wrong would record a field named "city,country" — which is
to say none — and an unknown name stops the generator before it writes
anything, listing what IS accepted. An install that quietly records less
than the operator asked for is discovered years later by the person who
needed the evidence.

The runtime keeps one setter per field. That is not duplication: the
per-field settings are the posture a host reads and reviews, and each
one is a separate decision about what to keep about a person. This
changes the command line only, and naming two fields still enables
exactly two.

744 runs, 0 failures, 5 skips; RuboCop clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`Clickwrap::Linter` scans every render in development and test, and that
was hardcoded, undocumented, and unturnoffable. A host that disagreed —
because its own tooling covers the same ground, or because it wants the
scan in a staging environment — had nowhere to say so, and a warning
nobody can turn off is one people learn to scroll past.

`config.lint_presentations` answers for both halves: `false` silences it
anywhere, `true` runs it anywhere, and `nil` (the default) keeps
deciding from the environment. Three answers rather than two, because
"decide for me" is not a synonym for "off".

The README gains the paragraph the linter never had: what it looks for,
that findings are log lines and never exceptions, and why it stays out
of production.

745 runs, 0 failures, 5 skips; RuboCop clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`lib/clickwrap.rb` requires the document renderer at load time, and the
renderer requires `loofah` and `rails-html-sanitizer`. Neither was in
the gemspec. They resolved only because Action View happens to depend on
them — an accident of somebody else's dependency graph, which is a poor
foundation for a library whose value is still working in ten years.

Declared rather than made lazy, because lazy would be a fiction:
sanitizing is how stored bytes become the representation a person is
offered, its identity and version are recorded in the provenance beside
the digest, and the default renderer does it on essentially every
publish. Moving the require would relocate the load, not remove the
dependency.

This adds nothing to anybody's bundle — both were already installed
transitively — and the gemspec's list of deliberate NON-dependencies is
untouched. It states what the gem already loads, so the day Action
View's dependencies change, clickwrap does not find out in production.

745 runs, 0 failures, 5 skips; RuboCop clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`ControllerHelpers.registered_gates` and `@verified_gates` were bare
collections, lazily created and mutated from controller class bodies —
which Rails autoloads, and in development reloads, from more than one
thread. Registry and Identifier both take a lock for exactly this
reason; these two did not.

A lost entry here is not a crash. It is a `requires_clickwrap` gate that
quietly stopped being checked, discovered by the first person who walks
past it.

Both collections are now created eagerly, so there is no `||=` on a read
path for two threads to race, and every write goes through one mutex.
Reads stay lock-free — they happen on every gated request, and a
snapshot taken a microsecond before a reload is a snapshot of a valid
state either way. The boot sweep takes its snapshot under the lock and
verifies outside it, so nothing re-enters a non-reentrant mutex; the
test asserts that too, because a deadlock at boot would be a worse bug
than the one being fixed.

746 runs, 0 failures, 5 skips; RuboCop clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two doc gaps, both the same shape: the source is honest and the docs
quietly are not.

`recorded_after?` says in its own comment that it returns false for
either event predating the ordering migration, because an upgrade cannot
invent honest order for rows written before it. Neither the README nor
the integrating guide said so, and both showed it in a list of one-line
predicates where it reads as a plain comparison. A reader who treats
that `false` as "recorded before" concludes the opposite of the truth.
Both now say it means "not after, OR the order is not knowable", and
both say to use it as a guard rather than as proof of the reverse.

`Clickwrap::FrontMatter` is public, is what every document without an
explicit `version:` resolves its label through, and appeared in ZERO
documentation. So hosts that need the same two answers about the same
block write their own reader — and diverge in exactly two places: a
quoted value, and an unquoted trailing YAML comment. When they diverge,
a page and its receipt name different versions of the same file. The
README and the integrating guide now show both methods, name those two
behaviors specifically, and say the quiet part about `strip`: it changes
the rendered representation only, because the source digest covers the
exact bytes the file had, front matter included.

746 runs, 0 failures, 5 skips.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`documentation_examples_test.rb` evaluates declaration fences — 15 of
the README's 68 — and every generator test asserts on the TEXT of what
was written. Nothing ran the quickstart. It is the one document every
user reads and the only one nothing verified.

It runs now, in a subprocess, against a throwaway application built from
nothing but what the installer emitted plus the two files the quickstart
says you already have (a routes file to mount into, a user model to add
`has_clickwraps` to): install → migrate → declare → publish → render the
form through a real ActionView context → submit the token read back out
of that rendered HTML the way a browser would → the evidence row exists
→ the receipt verifies, both through the projection and through the
standalone verifier. Each `ok:` line is printed after the step happened,
so the output is a trace rather than a description.

It failed four times before it passed, and every failure was real —
all of them consequences of the previous commit emitting fewer tables,
and none of them reachable by the existing suite, whose dummy installs
every tier:

* Capture marked a presentation accepted without first asking whether
  the policy retains presentations. The event builder had always asked;
  this path had not, so a default install queried a table it does not
  have on every capture.
* `Receipt.find` eager-loaded the request-evidence annex, and the
  receipt body, the verification integrity check, and the retention
  planner all read optional tables unconditionally.
* Active Record encryption was declared against the annex at boot,
  which reads the column — so an installation that records nothing
  could not boot. It is now applied where the table exists, AND again
  at the moment an annex is actually built, so an application whose
  database was unreachable during initialization still encrypts what it
  was told to instead of quietly writing plain text.

Rather than patch call sites, every Event association whose table is
optional now answers "there is nothing here" when that table was never
created. That is not a fallback: without the table, no row was ever
written, so it is the exact truth. Receipts still report all three
request-evidence categories, as `not_configured` — the true answer for
an application that decided not to collect any of it.

Also: the actor-class check resolves `config.actor_class_name` only once
there is a candidate to check, so an application whose every actor is
anonymous or system-owned never has to name a class it does not have.

747 runs, 0 failures, 5 skips; line 91.87% / branch 72.12%; RuboCop clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
"Every public constant is autoloadable from its own file" checked two
constants. The name was a claim about the whole gem; the body was a
spot check of `AnonymousActor` and `SystemActor` — the two that had
already broken once.

It now asks Zeitwerk's question of every file, from both directions:

* Each of the 69 files the loader manages must define exactly the
  constant its path names (with `models/` and `models/concerns/`
  collapsed, as the loader collapses them).
* No constant may ride along in a file Zeitwerk loads for a different
  one. That is the failure that actually bit: a second class in an
  existing file resolves perfectly under eager loading and raises
  NameError the first time a development app refers to it lazily,
  because there is no file to load it from.
* The 29 spine files the loader deliberately ignores are checked for
  the only thing that can go wrong with them — being dropped from the
  require list in lib/clickwrap.rb, which nothing would have noticed.

Between the two, all 98 files are accounted for.

It failed on the first run, on a real defect: `authority.rb` defines
`Authority`, `AuthorityRule`, `AuthorityDecision`, and
`AuthorityVerifier`, is explicitly required at boot like every other
multi-constant spine file, and was the only such file missing from
`ZEITWERK_IGNORED`. Three of those four had no file of their own, so a
development app that referred to `Clickwrap::AuthorityDecision` without
having touched `Clickwrap::Authority` first would have raised NameError
— and `AuthorityDecision` is public API, the value a host's own
authority adapter is required to return. Adding the file to the ignore
list is the same fix the other spine files already have.

A namespace module Zeitwerk builds from a DIRECTORY is exempt from the
ride-along rule: it resolves regardless of which file happened to write
`module X` first, so flagging `Clickwrap::Integrations` would have been
a false positive.

749 runs, 0 failures, 5 skips; RuboCop clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The floors were line 80 / branch 60 against actuals of 91.9 / 72.2. A
change could have deleted a third of this gem's branch coverage and
still passed, which is not a regression guard — it is a number.

They are now 88 / 68. The gap that remains is deliberate and is not
slack for new untested code: the CI database legs do not all reach the
same lines, because the update and delete protections are written for
PostgreSQL and the advisory-lock paths only execute on some adapters.
The floor has to hold on the leanest leg, so it sits below the richest
one, and the comment says so — otherwise the next person to read it
will "helpfully" raise it to 91 and break the matrix.

749 runs, 0 failures, 5 skips; line 91.89% / branch 72.15%; RuboCop clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The link beside a signup control pointed at the engine's rendering of the
published bytes: correct, immutable, and not the page anyone means when
they say "Terms of Service". Hosts have a formatted legal page with their
typography, their navigation, and their language switcher, and that is
where a link in a signup sentence should go.

    Clickwrap.document :terms,
      from: Rails.root.join("app/content/legal/terms.md"),
      link: "/legal/terms"

`link:` is both the presented path and the signed one, so the evidence
never cites a different target from the link somebody could press. The
trade is the host's and it is explicit in the declaration: 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 — what changes is which URL the receipt says was offered.
Leave `link:` off and nothing moves.

Precedence, in the one place that decides it:

  1. a resolver passed at the call site — the host naming this document's
     location on THIS screen, which beats any general declaration;
  2. the document's declared `link:`;
  3. the render context's default resolver: the mounted engine route with
     this request's Hotwire Native treatment attached, which is why it is
     handed the declared link too — a native render absolutizes a host
     page exactly as it absolutizes an engine path;
  4. the engine's own routes, which still refuse to sign a link an
     unmounted engine would resolve to nothing.

That refusal is about the ENGINE's route, so a policy whose every document
names a host page now presents on an application with no mount at all —
and the refusal keeps firing for every document that does not.

A link is rendered as an href and signed into a manifest, so the scheme is
checked at boot rather than at the moment somebody presses it:
`javascript:`, `data:`, a bare word, and protocol-relative `//host` are all
refused with the sentence that fixes them.

The wiring behind this splits one presenter keyword in two.
`document_version_path_with:` stays the host's own resolver and keeps
winning outright; the framework's own wiring moved to
`default_document_version_path_with:`, which is what it always was — the
engine fallback with the render's navigation treatment attached, asked
last. `clickwrap_document_version_path_for_presentation` gained a
`declared_link:` keyword for the same reason: it is the only method that
can apply native treatment, and the presenter is the only object that
knows which document a version belongs to.

The dummy host now reads its Terms and Privacy Notice on its own pages,
with the link labels a real product uses, and every other document there
keeps the engine route — so the suite exercises both halves.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A signup screen asks for two ordinary things — agree to the Terms,
acknowledge the Privacy Notice — and the presenter had exactly one way to
offer them: two statements, therefore two of everything. The presenter can
now compose them:

    I agree to the Terms of Service and I acknowledge the Privacy Policy.

One fragment per kind, from four new i18n keys, with `%{documents}`
marking where the links go. Fragments are kept SPLIT around that point
rather than as a template with a placeholder in it, so a view drops real
links into the middle of a sentence without ever concatenating markup into
translated text.

Composing is a decision about presentation and nothing else. The statements
keep their own kinds, their own documents, their own assertions, and their
own lifecycles, because "agreed to the Terms" and "acknowledged the Privacy
Notice" stay different facts no matter how few boxes it took to say both.
`Result#combined` describes the offer; `Result#itemized_statements` is
everything the line could not honestly absorb.

What it refuses to absorb is the point:

  * a consent, of any shape — unbundled consent is the requirement, and
    only `agreement` and `acknowledgment` are composable at all, so there
    is no arrangement of a consent statement that gets into the line;
  * an optional statement, which the line would silently make required;
  * a recorded yes/no, whose "no" would become an unrecorded silence;
  * a statement with a withdrawal route, which belongs beside its control;
  * copy the application wrote itself, which it wrote for a reason.

A locale that has not translated the connective words composes nothing and
itemizes instead: half an English sentence in somebody else's language is
worse than two tidy lines in their own. Same for a fragment translated
without a `%{documents}` in it — the documents are not dropped, the line is.

`combined: false` on `Clickwrap.present` turns the whole thing off. It
reaches the presenter rather than the view on purpose: the manifest has to
sign what was actually offered, and a flag that only changed the markup
would sign one shape and render another.

Nothing renders this yet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The signup clickwrap looked like a form. Two checkboxes, two "Required"
flags, two version labels, two "(opens in a new tab)" hints, and four
document links stacked under the sentences that named them — eleven pieces
of interface for a thing a person reads in four seconds. It now looks like
a sentence:

    [ ] I agree to the Terms of Service and I acknowledge the Privacy Policy.

The links are IN the line, where the words for them are. The label IS the
line, so pressing the words works and a screen reader announces the
sentence and the control together.

WHAT WENT AWAY, and why none of it is missed:

  * "Required". The `required` attribute stays, as progressive enhancement,
    and THE SERVER DECIDES stays true — but a page that has to print the
    word is a page that expects to be argued with.
  * "Version 2026-08-15". A version label beside a checkbox is a fact
    nobody can act on. On a receipt it is the entire point, and the receipt
    screens are untouched.
  * The visible "(opens in a new tab)". Kept as an sr-only span, and still
    only rendered when the link really does open one — the honesty rule was
    never the problem, the clutter was.
  * The list of documents under each statement. There is nothing to list
    when the links are in the sentence.

WHAT DID NOT GO AWAY, which is the whole design:

  * The evidence. One control, several statements — the server fans the one
    signed answer out to every statement the manifest says it covered, and
    the event records each with its own kind, documents, assertion, and
    lifecycle, exactly as before. An unticked or absent control refuses all
    of them.
  * The substitution defense, which now holds over the sentence a person
    actually saw: the manifest signs the composed wording, the keys the one
    control covers, and which key it is submitted under. An itemized
    manifest is byte-identical to the ones this gem has always written —
    the key is absent, not null.
  * Everything the line could not honestly absorb, which keeps its own
    control below it: an optional consent with its withdrawal route, a
    recorded yes/no in its fieldset, copy an application wrote itself. A
    policy with nothing composable — the multi-attestation operator rails,
    the withdrawal authorization — renders exactly as it did yesterday.

`combined: false` on `form.clickwrap` and `form.clickwrap_fields` asks for
the itemized shape. One boolean, no style registry, and it reaches the
presenter, so the manifest signs the shape that was offered.

One answer becoming several is checked against the FROZEN REVISION rather
than trusted because it arrived signed: every covered statement must exist,
be required, and be a plain checkbox. Fanning a ticked box out onto an
optional consent would manufacture a grant nobody gave; onto a choice, a
decision nobody made. Neither is reachable from the presenter, and now
neither is reachable from a token either.

The linter keeps both rules it had — a preselected control, an action
before the block — because both still read correctly against one control
covering three acts. It gains one: a page rendering separate controls for
statements the signed offer already covers. That is where an ejected view
lands after this change, and while the server overwrites those answers
rather than believing them, the page is offering a choice nobody has.

The error summary now takes control-level entries instead of statements, so
one box gets one message however many acts it answered. Three lines
pointing at the same checkbox is a list of the page's internals, not of a
person's problems.

Both partials got shorter.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The README opened with `form.clickwrap :signup` and then never showed what
it puts on the page, which was survivable while the answer was "some
checkboxes" and is not now that the answer is a specific sentence. It shows
the line, says what went away and where each removed thing went instead
(the `required` attribute stays, the new-tab hint is announced, versions
live on receipts), and says plainly what will never join the line and why —
with the `link:` option in the first document example, since a link in a
signup sentence should go to the page a person reads.

The accessibility guide had three sections that are now wrong in the
specific way that matters most: it promised "Required and optional are
rendered as text", "a visible 'opens in a new tab' hint", and "exactly one
control per act". The first two described chrome that is gone; the third
described a rule the composed line deliberately bends, and the guide now
says exactly how far — one control may answer several statements when the
server signs which ones, and may never absorb an answer somebody could
want to give differently. Its host checklist gained the two things a host
can now get wrong: a `.clickwrap-sr-only` rule turned into `display: none`,
and a sentence a screen reader reads as fragments.

The integrating guide's custom-surface example iterated `statements`, which
on a composed policy now renders a second box for a statement the first one
already answers. It iterates `itemized_statements`, and the consent guide
picks up the one paragraph a reader of "if a single control would need two
answers, it is two controls" will immediately want.

The installer says what its one line renders and how to point the links at
real pages. The views generator says the thing an ejected copy has to keep.

Also: `Combined#required?`, so a custom surface can hand the composed
control to `clickwrap_statement_check_box` and get the same markup contract
a statement gets — which is what the guide now tells people to do.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
88/68 was set against 91.9/72.2 actual. The redesign landed with its tests
and the actuals held at 92.09/72.60, so the floor had drifted four points
below the thing it is meant to catch — enough room to delete every test of
the composed line and still go green.

91/71, about a point of room in each on the leanest leg (sqlite, which is
where the PostgreSQL-only update and delete protections do not execute).
The comment now names the actuals it was set against, so the next person
raising it does not have to re-measure to know whether it has drifted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The composed line and a statement's own control are the same thing on the
page — a checkbox, a label carrying the words the server offered, an error
tied to it by id — and they were two copies of that markup in two files.
So `_fields.html.erb` carried a second control block, and a host that
ejected `_statement.html.erb` to restyle its checkbox got everything
restyled except the line their signup actually shows.

`Presenter::Combined` now answers everything a statement answers about a
CONTROL — name, id, error id, required, choices, documents, withdrawal path
— and nothing about an act, because it is not one. `_fields` walks one list
of controls; the statement partial renders any of them; and the one thing
that genuinely differs, whether the label is an assertion or a sentence
with links in it, is `clickwrap_statement_label`.

An ejected statement partial now styles the composed line for free.

Markup and logic across the three shared partials: 145 lines to 125, with
`_statement` itself down from 91 to 64.

Also fixes a real typographic bug the unification made visible: the sr-only
new-tab hint was separated from its link by a laid-out space, so the
sentence rendered as "…Privacy Policy ." with a gap before its own full
stop, and "…Terms of Service  and" with two. The space now lives inside the
span, which is out of flow with it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The receipt recorded what each act asserted and the words on the button,
and nothing about the words a person actually read — which, now that the
default offer is one composed sentence covering two acts, is the exact
thing a substitution argument is about. "I agree to the Terms." is what the
event stores about one statement; "I agree to the Terms of Service and I
acknowledge the Privacy Policy." is what was on the screen.

`presentation.combined_sentence` and `presentation.combined_statements`
join the receipt body, mirrored in the verifier's projection so the
duplication check still compares like with like, and shown on the HTML
receipt and the engine's receipt screen. Both are absent — not null — on an
itemized presentation, which is every receipt written before this change,
so the frozen golden fixtures verify byte for byte exactly as they did.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The floors sit about a point above the actuals now, which makes an accurate
reading matter. SimpleCov merges resultsets within its merge timeout, so a
full run that follows a `TEST=one_file.rb` run reports a number neither of
them produced — low enough, at these floors, to fail a green suite and send
somebody hunting for a regression that is not there.

Measure on a clean coverage directory. Written down where the numbers are.

Also records the receipt's new `combined_sentence` in the changelog.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two founder observations from the first real look at the composed line:

The box was 1.15rem fixed — sized for nothing in particular — and flex
`baseline` aligns a checkbox's BOTTOM edge to the text baseline, so it
floated high beside small text and towered over it. Now 1em, optically
centered against the first line: margin-top (1lh − 1em)/2, with an em
fallback for engines without lh.

The links wore conventional link blue with a full-strength underline,
which made the two document names the loudest thing in a sentence whose
job is to be read as one plain statement. Now they inherit the text
color and carry a 1px underline at 45% strength — still a non-color
distinguisher, so the distinction survives color-blindness and
forced-colors modes — firming to full on hover and focus as the
affordance. Receipts keep their conventional link styling; a receipt is
a document you navigate, not a sentence you read.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant