Implement clickwrap 0.1.0 - #1
Draft
rameerez wants to merge 75 commits into
Draft
Conversation
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
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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