clickwrap 0.0.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop.yml +90 -0
- data/CHANGELOG.md +650 -0
- data/README.md +830 -1204
- data/SECURITY.md +33 -0
- data/app/assets/stylesheets/clickwrap.css +245 -0
- data/app/controllers/clickwrap/application_controller.rb +79 -0
- data/app/controllers/clickwrap/captures_controller.rb +145 -0
- data/app/controllers/clickwrap/document_versions_controller.rb +71 -0
- data/app/controllers/clickwrap/receipts_controller.rb +115 -0
- data/app/controllers/clickwrap/withdrawals_controller.rb +60 -0
- data/app/helpers/clickwrap/engine_helper.rb +97 -0
- data/app/views/clickwrap/captures/show.html.erb +34 -0
- data/app/views/clickwrap/receipts/index.html.erb +38 -0
- data/app/views/clickwrap/receipts/show.html.erb +91 -0
- data/app/views/clickwrap/shared/_error_summary.html.erb +39 -0
- data/app/views/clickwrap/shared/_fields.html.erb +100 -0
- data/app/views/clickwrap/shared/_statement.html.erb +105 -0
- data/app/views/clickwrap/withdrawals/new.html.erb +30 -0
- data/config/locales/en.yml +160 -0
- data/config/locales/es.yml +154 -0
- data/config/routes.rb +41 -0
- data/exe/clickwrap +374 -0
- data/guides/README.md +30 -0
- data/guides/accessibility.md +249 -0
- data/guides/consent-and-lifecycle.md +299 -0
- data/guides/integrating.md +610 -0
- data/guides/integrity.md +212 -0
- data/guides/migrating.md +335 -0
- data/guides/naming.md +320 -0
- data/guides/organizations.md +320 -0
- data/guides/receipts-and-verification.md +415 -0
- data/guides/request-evidence.md +512 -0
- data/guides/retention-and-legal-holds.md +438 -0
- data/lib/clickwrap/actor_proxy.rb +147 -0
- data/lib/clickwrap/anonymous_actor.rb +47 -0
- data/lib/clickwrap/authority.rb +174 -0
- data/lib/clickwrap/canonical_json.rb +216 -0
- data/lib/clickwrap/capture/event_builder.rb +220 -0
- data/lib/clickwrap/capture/presentation_verifier.rb +521 -0
- data/lib/clickwrap/capture.rb +650 -0
- data/lib/clickwrap/configuration.rb +1129 -0
- data/lib/clickwrap/controller_helpers.rb +758 -0
- data/lib/clickwrap/current_state.rb +282 -0
- data/lib/clickwrap/digest.rb +125 -0
- data/lib/clickwrap/doctor.rb +418 -0
- data/lib/clickwrap/document_definition.rb +255 -0
- data/lib/clickwrap/document_renderer.rb +83 -0
- data/lib/clickwrap/document_renderers/markdown.rb +175 -0
- data/lib/clickwrap/document_renderers/markdown_rails.rb +126 -0
- data/lib/clickwrap/dsl/policy_builder.rb +462 -0
- data/lib/clickwrap/dsl/retention_builder.rb +89 -0
- data/lib/clickwrap/durable_commit_callback.rb +37 -0
- data/lib/clickwrap/engine.rb +187 -0
- data/lib/clickwrap/errors.rb +181 -0
- data/lib/clickwrap/form_builder_extensions.rb +341 -0
- data/lib/clickwrap/front_matter.rb +67 -0
- data/lib/clickwrap/identifier.rb +112 -0
- data/lib/clickwrap/import/external_receipt.rb +241 -0
- data/lib/clickwrap/import/fine_print.rb +290 -0
- data/lib/clickwrap/import/legacy.rb +450 -0
- data/lib/clickwrap/integrations/organizations_authority.rb +81 -0
- data/lib/clickwrap/integrity/anchor.rb +130 -0
- data/lib/clickwrap/integrity/attestation_reconciler.rb +114 -0
- data/lib/clickwrap/integrity/attestor.rb +221 -0
- data/lib/clickwrap/integrity/chain.rb +313 -0
- data/lib/clickwrap/integrity/timestamp.rb +143 -0
- data/lib/clickwrap/ip_geolocation/location.rb +112 -0
- data/lib/clickwrap/ip_geolocation/null_resolver.rb +35 -0
- data/lib/clickwrap/ip_geolocation/resolver.rb +97 -0
- data/lib/clickwrap/ip_geolocation/static_resolver.rb +107 -0
- data/lib/clickwrap/ip_geolocation/trackdown_resolver.rb +330 -0
- data/lib/clickwrap/ip_geolocation.rb +16 -0
- data/lib/clickwrap/lifecycle.rb +534 -0
- data/lib/clickwrap/linter.rb +382 -0
- data/lib/clickwrap/localized_text.rb +101 -0
- data/lib/clickwrap/macros.rb +203 -0
- data/lib/clickwrap/models/application_record.rb +20 -0
- data/lib/clickwrap/models/chain_head.rb +79 -0
- data/lib/clickwrap/models/concerns/has_clickwraps.rb +55 -0
- data/lib/clickwrap/models/disposition_plan.rb +208 -0
- data/lib/clickwrap/models/document.rb +46 -0
- data/lib/clickwrap/models/document_version.rb +163 -0
- data/lib/clickwrap/models/event.rb +743 -0
- data/lib/clickwrap/models/event_document.rb +79 -0
- data/lib/clickwrap/models/event_statement.rb +92 -0
- data/lib/clickwrap/models/external_action.rb +150 -0
- data/lib/clickwrap/models/integrity_attestation.rb +90 -0
- data/lib/clickwrap/models/legal_hold.rb +81 -0
- data/lib/clickwrap/models/policy_revision.rb +115 -0
- data/lib/clickwrap/models/presentation.rb +59 -0
- data/lib/clickwrap/models/receipt_access.rb +53 -0
- data/lib/clickwrap/models/recording_sequence.rb +21 -0
- data/lib/clickwrap/models/request_evidence.rb +378 -0
- data/lib/clickwrap/models/statement_identity_lock.rb +38 -0
- data/lib/clickwrap/models/statement_state.rb +130 -0
- data/lib/clickwrap/pending_receipt.rb +177 -0
- data/lib/clickwrap/policy.rb +283 -0
- data/lib/clickwrap/presentation_manifest.rb +210 -0
- data/lib/clickwrap/presenter.rb +729 -0
- data/lib/clickwrap/privacy.rb +419 -0
- data/lib/clickwrap/protected_outcome.rb +120 -0
- data/lib/clickwrap/receipt.rb +606 -0
- data/lib/clickwrap/receipt_html.rb +235 -0
- data/lib/clickwrap/receipt_verifier.rb +978 -0
- data/lib/clickwrap/reference.rb +44 -0
- data/lib/clickwrap/registration.rb +236 -0
- data/lib/clickwrap/registry.rb +54 -0
- data/lib/clickwrap/remediation_token.rb +155 -0
- data/lib/clickwrap/request_evidence_extractor.rb +590 -0
- data/lib/clickwrap/request_evidence_policy.rb +261 -0
- data/lib/clickwrap/retention/applier.rb +231 -0
- data/lib/clickwrap/retention/disposition.rb +221 -0
- data/lib/clickwrap/retention/planner.rb +502 -0
- data/lib/clickwrap/retention_class.rb +97 -0
- data/lib/clickwrap/reviewed_text.rb +28 -0
- data/lib/clickwrap/schema_requirements.rb +196 -0
- data/lib/clickwrap/services/authorize_external_action.rb +149 -0
- data/lib/clickwrap/services/load_policies.rb +69 -0
- data/lib/clickwrap/services/publish_documents.rb +251 -0
- data/lib/clickwrap/services/validate_policy_references.rb +166 -0
- data/lib/clickwrap/statement.rb +248 -0
- data/lib/clickwrap/subject_fingerprint.rb +28 -0
- data/lib/clickwrap/submission.rb +169 -0
- data/lib/clickwrap/system_actor.rb +31 -0
- data/lib/clickwrap/test_helpers.rb +676 -0
- data/lib/clickwrap/testing.rb +211 -0
- data/lib/clickwrap/trusted_proxy_configuration.rb +92 -0
- data/lib/clickwrap/verification.rb +504 -0
- data/lib/clickwrap/version.rb +12 -1
- data/lib/clickwrap/view_helpers.rb +190 -0
- data/lib/clickwrap/vocabulary.rb +294 -0
- data/lib/clickwrap.rb +497 -7
- data/lib/generators/clickwrap/document_generator.rb +164 -0
- data/lib/generators/clickwrap/hardening_generator.rb +177 -0
- data/lib/generators/clickwrap/install_generator.rb +1287 -0
- data/lib/generators/clickwrap/link_generator.rb +56 -0
- data/lib/generators/clickwrap/policy_generator.rb +118 -0
- data/lib/generators/clickwrap/templates/clickwrap_hardening.rb.erb +256 -0
- data/lib/generators/clickwrap/templates/clickwrap_policies.rb.erb +192 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_external_action_tables.rb.erb +128 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_integrity_tables.rb.erb +157 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_presentation_tables.rb.erb +160 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_request_evidence_tables.rb.erb +180 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_retention_tables.rb.erb +174 -0
- data/lib/generators/clickwrap/templates/create_clickwrap_tables.rb.erb +568 -0
- data/lib/generators/clickwrap/templates/initializer.rb.erb +439 -0
- data/lib/generators/clickwrap/templates/link_clickwrap_event_migration.rb.erb +12 -0
- data/lib/generators/clickwrap/templates/policy.rb.erb +31 -0
- data/lib/generators/clickwrap/templates/policy_test.rb.erb +56 -0
- data/lib/generators/clickwrap/templates/privacy.md.erb +58 -0
- data/lib/generators/clickwrap/templates/terms.md.erb +49 -0
- data/lib/generators/clickwrap/upgrade_generator.rb +50 -0
- data/lib/generators/clickwrap/views_generator.rb +101 -0
- data/lib/tasks/clickwrap.rake +569 -0
- metadata +311 -16
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
# Receipts and verification
|
|
2
|
+
|
|
3
|
+
A receipt answers "show me exactly what the application recorded." There is one canonical JSON body
|
|
4
|
+
per event and one HTML projection of the same facts — never a different set of facts, and never
|
|
5
|
+
a stronger claim than the JSON makes.
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
receipt = Clickwrap.receipt(event_id)
|
|
9
|
+
|
|
10
|
+
receipt.to_canonical_json # the verifiable bytes
|
|
11
|
+
receipt.to_html # the human projection
|
|
12
|
+
receipt.verify # against this application's data
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
`to_pdf` deliberately raises. A PDF library is not a dependency of an evidence gem, and a PDF
|
|
16
|
+
is a rendering of the receipt rather than the record. Run your own pipeline over `to_html` if
|
|
17
|
+
you need one.
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Anatomy
|
|
22
|
+
|
|
23
|
+
Everything below describes the released receipt schema. Keys whose value would be empty are
|
|
24
|
+
omitted rather than written as `null` — the one exception being request evidence, which always
|
|
25
|
+
reports an explicit state object.
|
|
26
|
+
|
|
27
|
+
### Top level
|
|
28
|
+
|
|
29
|
+
| Key | What it holds |
|
|
30
|
+
|---|---|
|
|
31
|
+
| `schema` | `"clickwrap.receipt.v1"`. A verifier that does not know this name stops rather than guessing |
|
|
32
|
+
| `event_id` | The stable identifier the domain row can reference |
|
|
33
|
+
| `event_type` | What produced the event: `capture`, `withdrawal`, `correction`, `supersession`, `expiry`, `consumption`, `revocation`, `renewal`, `scope_change`, `exemption`, `imported_legacy`, `external_receipt`, `disposition`, `legal_hold_placed`, `legal_hold_released`, `receipt_access`, or `provider_outcome` |
|
|
34
|
+
| `recorded_at_by_server` | When this application wrote the row down |
|
|
35
|
+
| `occurred_at` | When the act happened, according to whatever recorded it. For a live capture these coincide; for an import they usually do not, and the gap is itself evidence |
|
|
36
|
+
| `verifier_instructions` | A sentence telling a reader how to check the file without this application |
|
|
37
|
+
|
|
38
|
+
### `policy`
|
|
39
|
+
|
|
40
|
+
| Key | What it holds |
|
|
41
|
+
|---|---|
|
|
42
|
+
| `key` | The policy key as declared in `Clickwrap.policy` |
|
|
43
|
+
| `revision` | The digest of the compiled policy revision frozen at capture. Historical receipts do not need current source code to explain what the policy meant |
|
|
44
|
+
| `retention_class` | Which retention class governed this event |
|
|
45
|
+
|
|
46
|
+
### `actor`
|
|
47
|
+
|
|
48
|
+
| Key | What it holds |
|
|
49
|
+
|---|---|
|
|
50
|
+
| `type` | The actor model's class name |
|
|
51
|
+
| `reference` | The stable reference produced by `config.identify_actor_with`: a model's `clickwrap_actor_reference`, otherwise GlobalID when available, otherwise `ClassName/id`. The recorded string survives the row being deleted |
|
|
52
|
+
| `attribution.method` | One of `authenticated_session`, `account_registration`, `operator_session`, `api_credential`, `anonymous_identifier`, `system_process`, `imported_provider`, `unknown`. None of these is an identity claim; each says which application-supplied context was recorded |
|
|
53
|
+
| `attribution.authenticated` | True only when the method was `authenticated_session` |
|
|
54
|
+
| `authentication_method` | The host's own description from `config.describe_authentication_with`. Its default claims `{ method: :authenticated_session }` only when the controller's configured current-actor method returns someone, and `{}` otherwise — a signup form and a controller with no authentication at all both record nothing rather than a claim nobody checked |
|
|
55
|
+
| `snapshot` | Only the actor fields the host named in `config.snapshot_actor_with`. Clickwrap never serializes a whole model into evidence |
|
|
56
|
+
| `acting_for` | Present only for delegated action: the represented party's type and reference, the authority source, the authority role, and when authority was verified — as four separate facts. Clickwrap does not decide whether that authority was sufficient |
|
|
57
|
+
| `tenant` | The tenant key, when the capture had one |
|
|
58
|
+
| `subject.reference` / `subject.fingerprint` | Which domain object the act was about, and the host-computed fingerprint that binds it |
|
|
59
|
+
|
|
60
|
+
### `acts[]`
|
|
61
|
+
|
|
62
|
+
One entry per statement. A policy with several statements produces one event and one receipt,
|
|
63
|
+
but the statements never collapse into one meaning.
|
|
64
|
+
|
|
65
|
+
| Key | What it holds |
|
|
66
|
+
|---|---|
|
|
67
|
+
| `statement` | The statement key |
|
|
68
|
+
| `kind` | `agreement`, `acknowledgment`, `consent`, `declaration`, `attestation`, or `authorization` |
|
|
69
|
+
| `action` | What was recorded for this kind — see [the lifecycle guide](consent-and-lifecycle.md) |
|
|
70
|
+
| `assertion` | The **resolved sentence** bound to the generated presentation, not an I18n key. A key's meaning can change in a later deploy; then the receipt no longer says what the server asked |
|
|
71
|
+
| `locale` | The locale that sentence was resolved in |
|
|
72
|
+
| `required` / `answered` / `answer` | Whether the statement was required, whether it was answered, and what the answer was |
|
|
73
|
+
| `purpose` | The purpose key, for consent |
|
|
74
|
+
| `expires_at` | When this act stops being current, for kinds that can expire |
|
|
75
|
+
| `one_time` | Present only when the act is a one-time authorization |
|
|
76
|
+
| `subject_fingerprint` | The fingerprint recorded for this statement's subject binding |
|
|
77
|
+
|
|
78
|
+
### `documents[]`
|
|
79
|
+
|
|
80
|
+
| Key | What it holds |
|
|
81
|
+
|---|---|
|
|
82
|
+
| `statement` | Which statement this document was attached to |
|
|
83
|
+
| `key`, `version`, `locale` | Which exact document version was bound |
|
|
84
|
+
| `source_media_type` / `source_digest` | The media type and digest of the original source bytes, as they stood at capture |
|
|
85
|
+
| `rendered_media_type` / `rendered_digest` | The media type and digest of the rendered representation actually offered |
|
|
86
|
+
| `renderer` | The named renderer and sanitizer versions that produced the rendered bytes, when applicable |
|
|
87
|
+
| `ordinal` | The document's order within the presentation |
|
|
88
|
+
|
|
89
|
+
Two digests, because "this Markdown file existed" and "this rendered representation was
|
|
90
|
+
offered" are different claims. The digests are copied onto the event rather than only
|
|
91
|
+
referenced, so verification can detect a document-version row edited in place instead of
|
|
92
|
+
trusting the column it is supposed to be checking.
|
|
93
|
+
|
|
94
|
+
### `presentation`
|
|
95
|
+
|
|
96
|
+
| Key | What it holds |
|
|
97
|
+
|---|---|
|
|
98
|
+
| `manifest_digest` | The canonical digest of the presentation manifest the server generated |
|
|
99
|
+
| `submit_button_text` | The exact call-to-action wording in that manifest |
|
|
100
|
+
| `combined_sentence` | Present only when the offer was ONE control carrying one composed sentence ("I agree to the Terms of Service and I acknowledge the Privacy Policy."). The acts say what was recorded; this says what the person read, which is what a substitution argument is actually about |
|
|
101
|
+
| `combined_statements` | Which statement keys that one answer covered |
|
|
102
|
+
| `locale` | The locale the presentation was rendered in |
|
|
103
|
+
| `capture_channel` | `web_browser`, `native_app`, `api_client`, `operator`, `background_job`, `imported_provider`, or `system` |
|
|
104
|
+
| `offered_at` | When the manifest was issued |
|
|
105
|
+
| `proves` | A sentence, inside the receipt itself, saying what this establishes: that the server generated the manifest and accepted a submission bound to it — and not that the person read or understood the documents, saw particular pixels, or received a sufficient interface |
|
|
106
|
+
|
|
107
|
+
### `outcome`, `lifecycle`, `provider`
|
|
108
|
+
|
|
109
|
+
`outcome` is the protected action's in-transaction result snapshot when the policy configured
|
|
110
|
+
`record_protected_outcome_with`. The callback receives the block's return value and should use
|
|
111
|
+
`Clickwrap.protected_outcome(action:, record:, facts:, state: nil)`. The resulting reference,
|
|
112
|
+
non-empty canonical facts, and fingerprint over the complete claim are validated before commit;
|
|
113
|
+
a malformed or stale fingerprint rolls the domain action and evidence back together. Without a
|
|
114
|
+
recorder, the receipt says only that the named policy, bound subject, evidence event, and block
|
|
115
|
+
committed together — Clickwrap does not guess what a host method meant. A pending external outbox
|
|
116
|
+
does not claim a completed local outcome; its provider result is appended later.
|
|
117
|
+
|
|
118
|
+
`lifecycle` carries `root_event_id`, `predecessor_event_id`, and every successor event with its
|
|
119
|
+
type, time, reason, canonical event body, and event digest. This is where a withdrawal,
|
|
120
|
+
correction, supersession, expiry, consumption, or disposition shows up. Ordinary lifecycle
|
|
121
|
+
changes append instead of overwriting the earlier event; reviewed core disposition is the named
|
|
122
|
+
exception that removes a fixed payload while retaining a linked tombstone.
|
|
123
|
+
|
|
124
|
+
That makes an exported receipt a projection at the moment it was exported. A later export may
|
|
125
|
+
add lifecycle successors or integrity attestations, so its top-level `receipt_digest` may be
|
|
126
|
+
different even though both exports are valid. The embedded canonical `event` object and its
|
|
127
|
+
`integrity.event_digest` are the stable historical anchor for the original event: lifecycle
|
|
128
|
+
history grows by appending linked events rather than changing that body. Keep an exported file
|
|
129
|
+
if the exact export itself matters; do not use byte equality between exports as a current-state
|
|
130
|
+
test.
|
|
131
|
+
|
|
132
|
+
`provider` appears only on imported external receipts, and carries the provider name, its event
|
|
133
|
+
ID, its verification status, and a `note` stating plainly that Clickwrap did not present this
|
|
134
|
+
content or observe this action.
|
|
135
|
+
|
|
136
|
+
### `request_evidence`
|
|
137
|
+
|
|
138
|
+
Three keys — `ip_address`, `browser_user_agent`, `ip_geolocation` — each always present, each
|
|
139
|
+
carrying a `state`. See [the request-evidence guide](request-evidence.md) for the field-level
|
|
140
|
+
dictionary. What matters here is that raw values are **not** in the canonical body by default.
|
|
141
|
+
They live in a separately encrypted annex with its own authorization, retention, hold, and
|
|
142
|
+
disposition state, and the body carries only a keyed digest binding the two.
|
|
143
|
+
|
|
144
|
+
That boundary is the whole reason the annex exists. Welding an IP address into the core event
|
|
145
|
+
would force its schedule to match the agreement payload. Here each annex category can go on its
|
|
146
|
+
own clock while the retained core event stays intact and verifiable.
|
|
147
|
+
|
|
148
|
+
An export that reveals request evidence is a different document from a redacted one, so it
|
|
149
|
+
carries a different `receipt_digest`. That is correct: each file verifies as the file it
|
|
150
|
+
actually is.
|
|
151
|
+
|
|
152
|
+
### `retention`
|
|
153
|
+
|
|
154
|
+
`class`, `core_event_retained_until`, `retention_rule`, `core_event_disposed_at`, and
|
|
155
|
+
`on_legal_hold`. See [the retention guide](retention-and-legal-holds.md).
|
|
156
|
+
|
|
157
|
+
### `system`
|
|
158
|
+
|
|
159
|
+
`gem_version`, `application_version`, `template_version`, `canonical_schema_version`, and
|
|
160
|
+
`verifier_version` — so a reader can tell which code wrote the receipt and which code read it.
|
|
161
|
+
|
|
162
|
+
---
|
|
163
|
+
|
|
164
|
+
## The two digests
|
|
165
|
+
|
|
166
|
+
`integrity` carries both `receipt_digest` and `event_digest`. They are different values, they
|
|
167
|
+
cover different bytes, and confusing them is the single easiest way to overstate what a
|
|
168
|
+
verification result means.
|
|
169
|
+
|
|
170
|
+
| | `integrity.receipt_digest` | `integrity.event_digest` |
|
|
171
|
+
|---|---|---|
|
|
172
|
+
| Covers | This receipt body, with only `integrity.receipt_digest` removed, canonicalized per RFC 8785 | The **event's own** canonical body embedded at top-level `event`: a different object, with different keys, built when the event was written |
|
|
173
|
+
| Computed | When the receipt is produced, by whoever produced it | Once, at capture, and stored on the event row |
|
|
174
|
+
| Checkable from a file alone | **Yes.** | **Yes while the event payload is retained.** After reviewed core disposition, the verifier checks the retained tombstone and exact digest-linked disposition successor instead and labels the original digest check incomplete |
|
|
175
|
+
| Answers | "Has this exported projection changed since this digest was taken?" | "Does the embedded historical event body match the digest recorded when that event was finalized?" In-app verification additionally compares against the database row |
|
|
176
|
+
|
|
177
|
+
The exclusion of `integrity` is not a convenience. A digest cannot cover itself: the moment it
|
|
178
|
+
is written into the object, the object's bytes change and no amount of recomputation converges.
|
|
179
|
+
So the digest is taken over the body before `integrity.receipt_digest` is attached, and
|
|
180
|
+
verification reproduces that by removing exactly that field. Everything else — including the
|
|
181
|
+
rest of `integrity`, `verifier_instructions`, and any host `x_`-prefixed extension — is inside the digest. A key
|
|
182
|
+
excluded without being named would be a key anyone could edit freely.
|
|
183
|
+
|
|
184
|
+
The receipt embeds the event's canonical body under `event`, so the standalone verifier re-derives
|
|
185
|
+
`event_digest` and also checks every duplicated receipt projection against it. If reviewed
|
|
186
|
+
retention has removed the original payload, it cannot truthfully re-derive those deleted bytes;
|
|
187
|
+
it instead requires a minimal tombstone and exactly one digest-checked `disposition` successor
|
|
188
|
+
whose event ID, predecessor/root link, original digest, and disposition time agree. That check is
|
|
189
|
+
reported as incomplete/documented disposition, never as an ordinary verifying event digest.
|
|
190
|
+
|
|
191
|
+
`integrity` also carries `previous_event_digest`, `chain_scope`, and `chain_sequence` when
|
|
192
|
+
chaining is on; per-category request-evidence binding digests, algorithm, key ID, and binding
|
|
193
|
+
status; immutable timestamp/anchor attestation results; and `tier` plus `detects`, which state in
|
|
194
|
+
the receipt itself exactly what the assurance level detects. See [the integrity guide](integrity.md).
|
|
195
|
+
|
|
196
|
+
---
|
|
197
|
+
|
|
198
|
+
## Canonicalization
|
|
199
|
+
|
|
200
|
+
Receipts are digested and verified by code that may be years newer than the code that wrote
|
|
201
|
+
them, and by verifiers written in other languages. So the bytes have to be reproducible from
|
|
202
|
+
the data alone: no Ruby object serialization, no YAML, no hash insertion order, no database
|
|
203
|
+
column order, no locale-dependent number formatting.
|
|
204
|
+
|
|
205
|
+
The base is the [JSON Canonicalization Scheme, RFC 8785](https://www.rfc-editor.org/rfc/rfc8785)
|
|
206
|
+
*(technical standard)*. Two of its rules bite in practice, and Clickwrap implements both
|
|
207
|
+
explicitly rather than relying on Ruby's defaults:
|
|
208
|
+
|
|
209
|
+
- **Object keys sort by UTF-16 code units**, not UTF-8 bytes. Ruby compares strings by UTF-8,
|
|
210
|
+
which orders characters outside the Basic Multilingual Plane differently, so keys are encoded
|
|
211
|
+
to UTF-16BE before comparison.
|
|
212
|
+
- **Numbers follow the ECMAScript `Number::toString` algorithm.** Ruby writes `1.0` where
|
|
213
|
+
ECMAScript writes `1`, and `1.0e-05` where ECMAScript writes `0.00001`. Clickwrap rebuilds
|
|
214
|
+
the ECMAScript form from Ruby's shortest round-trip digits.
|
|
215
|
+
|
|
216
|
+
Integers whose magnitude exceeds 2^53 − 1 raise rather than serialize, because they cannot
|
|
217
|
+
survive the IEEE 754 double round trip RFC 8785 assumes and another verifier could not
|
|
218
|
+
reproduce the bytes. Record large identifiers as strings.
|
|
219
|
+
|
|
220
|
+
### The Clickwrap profile
|
|
221
|
+
|
|
222
|
+
Five additions on top of RFC 8785. They exist so two verifiers never disagree about what a
|
|
223
|
+
value meant:
|
|
224
|
+
|
|
225
|
+
| Rule | Why |
|
|
226
|
+
|---|---|
|
|
227
|
+
| **Timestamps are UTC strings with exactly six fractional digits and a `Z` suffix** (`"2026-08-15T12:34:56.123456Z"`) | Fixed width, so nobody has to decide whether a trailing zero was significant. Local offsets and variable precision are two verifiers' worth of disagreement |
|
|
228
|
+
| **Digests are `"<algorithm>:<lowercase hex>"`** (`"sha256:9f86d0…"`) | An auditor never has to guess which function produced a bare hex string, and a future release can add an algorithm without making old events unreadable. Keyed digests use `"hmac-sha256:…"` |
|
|
229
|
+
| **Identifiers are strings, never numbers** | A numeric ID is a lossy round trip through a double and a formatting decision waiting to happen |
|
|
230
|
+
| **A value that was never collected is an explicit state object**, never `null` and never a missing key | `null` and "absent" both mean four different things — not configured, unavailable, redacted, deleted. The state object says which |
|
|
231
|
+
| **Host extensions use keys prefixed `x_`** | So a verifier can tell your additions from the schema's, and so your additions are still inside the digest |
|
|
232
|
+
|
|
233
|
+
---
|
|
234
|
+
|
|
235
|
+
## Verifying inside the application
|
|
236
|
+
|
|
237
|
+
```ruby
|
|
238
|
+
result = receipt.verify
|
|
239
|
+
result.success? # => true
|
|
240
|
+
result.error # => :declaration_expired, or nil
|
|
241
|
+
result.message # localized human explanation
|
|
242
|
+
result.details # stable machine-readable facts, no surprise personal data
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
This path has the database, so it checks what a file cannot: that the event's own digest still
|
|
246
|
+
matches the row, that document-version rows still carry the digests the event recorded, that
|
|
247
|
+
the lifecycle state is what the policy requires, and that chain links line up with their
|
|
248
|
+
neighbours.
|
|
249
|
+
|
|
250
|
+
Across the whole store, or one event:
|
|
251
|
+
|
|
252
|
+
```bash
|
|
253
|
+
bin/rails clickwrap:verify
|
|
254
|
+
bin/rails clickwrap:verify EVENT_ID
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
Chain walks are their own operation and report their own vocabulary:
|
|
258
|
+
|
|
259
|
+
```ruby
|
|
260
|
+
result = Clickwrap::Integrity::Chain.verify
|
|
261
|
+
result.success? # => true
|
|
262
|
+
result.counts # => {"checked" => …, "verified" => …, "breaks" => 0, "scopes" => …}
|
|
263
|
+
result.first_break # => nil, or a Break with a stable `reason`
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
The break reasons are stable symbols so a monitor can branch on them without matching English:
|
|
267
|
+
`digest_does_not_match`, `previous_digest_does_not_link`, `sequence_gap`,
|
|
268
|
+
`earlier_events_missing`.
|
|
269
|
+
|
|
270
|
+
---
|
|
271
|
+
|
|
272
|
+
## Verifying outside the application
|
|
273
|
+
|
|
274
|
+
```ruby
|
|
275
|
+
Clickwrap::Receipt.verify(canonical_json, documents: document_files)
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
```bash
|
|
279
|
+
clickwrap verify receipt.json --documents ./receipt-documents
|
|
280
|
+
clickwrap verify receipt.json --documents ./receipt-documents --json
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
The CLI's dependency list is the feature. It requires JSON, the canonicalizer, the digest
|
|
284
|
+
helpers, the error classes, and the version constants — no Rails, no Active Record, no
|
|
285
|
+
database, no engine, no host application, no policy source, no network. A verifier that had to
|
|
286
|
+
boot the application which produced the evidence could only ever tell you that the application
|
|
287
|
+
agrees with itself.
|
|
288
|
+
|
|
289
|
+
It runs the applicable checks below and reports each one as passed, failed, or **skipped**:
|
|
290
|
+
|
|
291
|
+
| Check | What it does |
|
|
292
|
+
|---|---|
|
|
293
|
+
| `json_parses` | The file is a JSON object |
|
|
294
|
+
| `known_schema` | The `schema` value is one this verifier knows. An unknown schema **stops** verification rather than guessing at the meaning of a newer format |
|
|
295
|
+
| `canonical_bytes` | Whether the bytes are already canonical. Non-canonical bytes are re-canonicalized and reported, not failed — pretty-printing a receipt on its way through a bug tracker changes formatting, not meaning |
|
|
296
|
+
| `receipt_digest` | The recorded digest matches the canonicalized body with only `integrity.receipt_digest` excluded |
|
|
297
|
+
| `event_digest` | The embedded canonical event and every duplicated projection match the recorded event digest; disposed payloads take the documented-disposition path described above |
|
|
298
|
+
| `lifecycle:<event_id>` | Every embedded successor's canonical body, digest, type, and root/predecessor relationship agree |
|
|
299
|
+
| `integrity_attestation:<id>` | Every included attestation digest verifies and an upgraded tier has the exact verified capability it claims |
|
|
300
|
+
| `document:<key>@<version>` | Each supplied file hashes to the digest the receipt recorded. A document whose bytes you did not bring is reported `not_supplied` |
|
|
301
|
+
| `chain_linkage` | Chain fields, when present, are internally coherent. Not chained is reported as a configuration fact, not a finding |
|
|
302
|
+
|
|
303
|
+
Three states, not two, everywhere. A check that could not run is neither a pass nor a failure,
|
|
304
|
+
and collapsing it into either turns "we did not look" into "we looked and it was fine." A
|
|
305
|
+
skipped check never makes a result succeed and never makes it fail; it stays visible.
|
|
306
|
+
|
|
307
|
+
Files are matched to documents by key, version, and locale, most specific first:
|
|
308
|
+
`terms-2026-08-15-en.md`, then `terms-2026-08-15.md`, then `terms.en.md`, then `terms.md`, then
|
|
309
|
+
any file whose basename starts with `terms-`.
|
|
310
|
+
|
|
311
|
+
Exit status is `0` when every required check passed, `1` when any check failed, and `2` when no
|
|
312
|
+
check failed but a required document artifact was not supplied.
|
|
313
|
+
|
|
314
|
+
---
|
|
315
|
+
|
|
316
|
+
## What a successful verification does and does not establish
|
|
317
|
+
|
|
318
|
+
It **does** establish:
|
|
319
|
+
|
|
320
|
+
- the file is well-formed JSON in a receipt schema this verifier knows by name;
|
|
321
|
+
- its bytes are canonical under RFC 8785, so two readers digest the same thing;
|
|
322
|
+
- the digest the receipt carries matches the body it travels with, so accidental or ordinary
|
|
323
|
+
modification of those bytes is detected;
|
|
324
|
+
- the embedded event body and duplicated receipt projections match the event digest, or the
|
|
325
|
+
receipt contains a coherent documented core disposition instead of pretending deleted bytes
|
|
326
|
+
were re-derived;
|
|
327
|
+
- each document file you supplied hashes to the digest the receipt recorded for it; and
|
|
328
|
+
- any chain links present are consistent with each other.
|
|
329
|
+
|
|
330
|
+
It does **not** establish:
|
|
331
|
+
|
|
332
|
+
- **that the receipt was not fabricated.** A self-contained file verifying against itself shows
|
|
333
|
+
internal consistency and nothing about origin. A party who controlled the application, the
|
|
334
|
+
database, and the export could have produced every byte in it, including the digest, and this
|
|
335
|
+
verifier would say "verified" — because the only thing it can compare the bytes against is
|
|
336
|
+
the bytes;
|
|
337
|
+
- **when anything happened.** `recorded_at_by_server` is a time an application server wrote
|
|
338
|
+
down. It is the server's own clock, attested by nobody else;
|
|
339
|
+
- **who acted.** An actor reference identifies a record in somebody's database, not a person;
|
|
340
|
+
- **that a chain was not rewritten.** A single receipt can only show its own links are
|
|
341
|
+
coherent. Proving the chain itself is intact needs the neighbouring receipts and a head held
|
|
342
|
+
somewhere the database operator does not control;
|
|
343
|
+
- **that any of it is sufficient, adequately presented, or admissible anywhere.** That is not a
|
|
344
|
+
property of a file.
|
|
345
|
+
|
|
346
|
+
Origin and time evidence come from things this verifier cannot supply on its own: a verified
|
|
347
|
+
outside publication of the exact event-chain snapshot, an RFC 3161 or trust-service token over
|
|
348
|
+
the exact event digest, or a provider's own signed receipt. Where those exist they are reported as
|
|
349
|
+
exactly the assurance they supply. Where they do not, their absence is visible rather than
|
|
350
|
+
papered over by a check mark. Every result, in both the library and the CLI, prints that caveat
|
|
351
|
+
alongside the verdict.
|
|
352
|
+
|
|
353
|
+
---
|
|
354
|
+
|
|
355
|
+
## Exporting
|
|
356
|
+
|
|
357
|
+
```ruby
|
|
358
|
+
Clickwrap.export_receipt(
|
|
359
|
+
receipt,
|
|
360
|
+
requested_by: current_operator,
|
|
361
|
+
because: "Investigate dispute 2026-184",
|
|
362
|
+
include_ip_address: false,
|
|
363
|
+
include_browser_user_agent: false,
|
|
364
|
+
include_ip_geolocation: false
|
|
365
|
+
)
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
There is deliberately no `include_sensitive_context: true`. One flag that turns on three
|
|
369
|
+
different categories of personal data is exactly the kind of option that makes an operator's
|
|
370
|
+
intent unreviewable a year later.
|
|
371
|
+
|
|
372
|
+
Naming any category as `true` requires a non-empty `because:` and a `true` from
|
|
373
|
+
`config.authorize_unredacted_request_evidence_access_with`; the default callback returns
|
|
374
|
+
`false` for everyone. Every export appends a `ReceiptAccess` row recording who asked, why, and
|
|
375
|
+
which categories were included. Foreign event IDs return not found, so existence is not leaked.
|
|
376
|
+
|
|
377
|
+
---
|
|
378
|
+
|
|
379
|
+
## The golden-fixture policy
|
|
380
|
+
|
|
381
|
+
Released evidence formats are permanent. That is a stronger promise than semantic versioning,
|
|
382
|
+
and it has three parts the project holds itself to:
|
|
383
|
+
|
|
384
|
+
1. **`ReceiptVerifier::KNOWN_SCHEMAS` only ever grows.** A new gem version may stop *creating*
|
|
385
|
+
an old schema. It must never stop *verifying* one, because receipts exported under it are
|
|
386
|
+
out in the world and their whole value is that they still check out years later.
|
|
387
|
+
2. **A format change means a new explicit schema and a new branch of verification logic** —
|
|
388
|
+
never a silent reinterpretation of an old name. `CANONICAL_SCHEMA_VERSION` is deliberately
|
|
389
|
+
independent of the gem `VERSION`, and `VERIFIER_VERSION` moves only when receiver-side
|
|
390
|
+
verification changes in a way an auditor should be able to see in a receipt.
|
|
391
|
+
3. **Every released receipt schema, canonicalization profile, digest field, event action, and
|
|
392
|
+
lifecycle meaning gets a permanent fixture**, and the suite verifies every previously
|
|
393
|
+
released format on every run. The fixtures live in `test/fixtures/receipts/` — real
|
|
394
|
+
receipts, byte for byte, exactly as an earlier version exported them — with their document
|
|
395
|
+
bytes beside them, and `test/golden_receipts_test.rb` runs the current verifier against all
|
|
396
|
+
of them. They are never regenerated. When a format legitimately changes, the correct move is
|
|
397
|
+
to add a fixture under the new schema name and leave the old ones alone: a fixture updated
|
|
398
|
+
to match new behavior has stopped testing anything.
|
|
399
|
+
|
|
400
|
+
Two consequences for contributors. Anything touching public vocabulary or an evidence claim — a
|
|
401
|
+
receipt field, a lifecycle meaning, a sentence a receipt prints about what it proves — needs
|
|
402
|
+
the documentation change alongside the code plus a note on what happens to receipts already
|
|
403
|
+
written under the old behavior. And released migrations are never edited underneath an
|
|
404
|
+
installed application; upgrades add migrations and report their exact effects.
|
|
405
|
+
|
|
406
|
+
---
|
|
407
|
+
|
|
408
|
+
## Sources
|
|
409
|
+
|
|
410
|
+
| Source | Class |
|
|
411
|
+
|---|---|
|
|
412
|
+
| [RFC 8785, JSON Canonicalization Scheme](https://www.rfc-editor.org/rfc/rfc8785) | Technical standard |
|
|
413
|
+
| [NIST FIPS 180-4](https://csrc.nist.gov/pubs/fips/180-4/upd1/final) — SHA-2 is a hash standard, not a signature, identity, or time source | Technical standard |
|
|
414
|
+
| [RFC 3161](https://www.rfc-editor.org/info/rfc3161/) — a separate time-stamp protocol | Technical standard |
|
|
415
|
+
| The profile rules, the two-digest split, and the export design | Product-design inference |
|