clickwrap 0.0.0 → 0.1.0
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 +612 -0
- data/README.md +830 -1204
- data/SECURITY.md +33 -0
- data/app/assets/stylesheets/clickwrap.css +241 -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 +138 -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 +184 -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 +716 -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
data/guides/integrity.md
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# Integrity: five tiers and what each one actually detects
|
|
2
|
+
|
|
3
|
+
Clickwrap starts useful with an ordinary Rails database and lets serious applications add
|
|
4
|
+
assurance without changing the capture API. What does not change with the tier is the
|
|
5
|
+
vocabulary: each tier states exactly what it detects, and the receipt prints that sentence
|
|
6
|
+
itself so nobody has to infer it from a green check mark.
|
|
7
|
+
|
|
8
|
+
## Three things that are not the same thing
|
|
9
|
+
|
|
10
|
+
This is the section to read before the tier table, because most overclaims start by blurring
|
|
11
|
+
these together.
|
|
12
|
+
|
|
13
|
+
**SHA-2 is a hash standard.** It says nothing about who computed a digest, when, or whether
|
|
14
|
+
they were entitled to. [NIST FIPS 180-4](https://csrc.nist.gov/pubs/fips/180-4/upd1/final)
|
|
15
|
+
*(technical standard)*. A hash is not a signature, not an identity, and not a time source.
|
|
16
|
+
|
|
17
|
+
**RFC 3161 is a time-stamp protocol.** A time-stamp authority returns a token over a digest you
|
|
18
|
+
gave it. What that token is worth depends entirely on that authority, its practice statement,
|
|
19
|
+
its certificate status, and what a reader is willing to accept about it.
|
|
20
|
+
[RFC 3161](https://www.rfc-editor.org/info/rfc3161/) *(technical standard)*.
|
|
21
|
+
|
|
22
|
+
**eIDAS is a regulation about the legal effect of electronic signatures and seals.** Article 25
|
|
23
|
+
assigns a distinct effect to signatures at its qualified level, which is a different thing
|
|
24
|
+
again from a hash and from a timestamp token.
|
|
25
|
+
[Regulation (EU) No 910/2014, Article 25](https://eur-lex.europa.eu/eli/reg/2014/910/2024-05-20/eng)
|
|
26
|
+
*(law)*. Whether a given provider's output has that effect is a question about that provider,
|
|
27
|
+
not about this gem.
|
|
28
|
+
|
|
29
|
+
Two consequences that hold everywhere in Clickwrap:
|
|
30
|
+
|
|
31
|
+
- **A local hash detects modification of the bytes it covers, and nothing more.** It does not
|
|
32
|
+
establish who produced those bytes, when, or that a party controlling both the application
|
|
33
|
+
and the database could not have written the record and its digest together. The baseline
|
|
34
|
+
receipt says exactly that, in the receipt.
|
|
35
|
+
- **`recorded_at_by_server` is the application server's own clock.** It is called that
|
|
36
|
+
everywhere — in the column name, in the receipt, in the API — precisely so it is never
|
|
37
|
+
mistaken for a time attested by a third party. Clickwrap has no method, field, or option
|
|
38
|
+
named `signed_at`. A timestamp authority supplies something different, and when one is
|
|
39
|
+
configured its token and its own reported time are stored and labeled as the provider's.
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## The five tiers
|
|
44
|
+
|
|
45
|
+
| Tier | What you turn on | The exact claim |
|
|
46
|
+
|---|---|---|
|
|
47
|
+
| **Baseline** | Nothing. `config.digest_canonical_receipts_with = :sha256` is the default | The recorded digest detects accidental or ordinary modification of the bytes it covers. It does not establish who produced them, when, or that a party controlling both the application and the database could not have written both the record and the digest |
|
|
48
|
+
| **Database hardening** | `bin/rails generate clickwrap:hardening --database` and a migration | Rejects unsupported mutation paths within the documented database threat model. On PostgreSQL this is real update and delete protection; on SQLite and MySQL the generator says plainly what the database can and cannot reject rather than emitting something that looks like protection and is not |
|
|
49
|
+
| **Chained history** | `config.chain_event_history_with = :sha256` | Each event carries the digest of the one before it, so an event later rewritten or removed stops linking up with its successors. This makes a rewrite of history detectable for as long as the chain head remains trustworthy — and no more than that |
|
|
50
|
+
| **Independent anchoring** | `config.anchor_event_history_with = MyAnchor.new` | The exact chain scope, sequence, event ID, and event digest committed for each event are offered to an outside publication service. The tier upgrades only when the adapter records and verifies that exact snapshot; the claim is only ever as strong as the publication and its verifier |
|
|
51
|
+
| **Timestamp or trust-service provider** | `config.timestamp_receipts_with = MyRfc3161Provider.new` | Preserves exactly the assurance and validation status that provider supplies, verbatim. If the provider's own status is "unknown" or "expired", that is what travels into the receipt |
|
|
52
|
+
|
|
53
|
+
Three notes on the honest edges of that table.
|
|
54
|
+
|
|
55
|
+
**Chaining does not stop a fully privileged actor.** Whoever can write the events table can
|
|
56
|
+
usually write the chain-head table too, and a head living in the same database as the chain
|
|
57
|
+
cannot say otherwise. What the chain reliably catches is ordinary corruption, a well-meant
|
|
58
|
+
`update_column`, a restored partial backup, and a row edited by hand — which is most of what
|
|
59
|
+
actually goes wrong. It is precisely the remaining gap that the anchor adapter addresses.
|
|
60
|
+
|
|
61
|
+
**Anchoring and timestamps are adapter contracts, not bundled providers.** Both configuration
|
|
62
|
+
settings default to `nil`, so no provider call or attestation row exists until the host chooses
|
|
63
|
+
an adapter. `Clickwrap::Integrity::Anchor` and `Clickwrap::Integrity::Timestamp` are reference
|
|
64
|
+
base classes with explicit unavailable results; they are not installed implicitly. Clickwrap
|
|
65
|
+
ships no RFC 3161 client — no ASN.1 encoder, no HTTP client, no certificate-chain validation —
|
|
66
|
+
and adds no dependency that would. A host that needs one supplies an adapter that speaks to its
|
|
67
|
+
chosen service.
|
|
68
|
+
|
|
69
|
+
**The receipt's `integrity.tier` reports recorded, verified evidence — never configuration
|
|
70
|
+
alone.** It is `third_party_timestamp` only when an independently-verifiable timestamp
|
|
71
|
+
attestation over the event digest verifies; otherwise `external_event_anchoring` only when an
|
|
72
|
+
outside-publication attestation over the exact chain snapshot verifies; otherwise
|
|
73
|
+
`chained_history` when the event carries a chain scope; otherwise `baseline`. Database
|
|
74
|
+
hardening is checked separately by `bin/rails clickwrap:doctor`. Unavailable, failed, and issued
|
|
75
|
+
but unverified provider results remain visible in `integrity.attestations` without upgrading the
|
|
76
|
+
tier.
|
|
77
|
+
|
|
78
|
+
Enable what you need, explicitly:
|
|
79
|
+
|
|
80
|
+
```ruby
|
|
81
|
+
config.digest_canonical_receipts_with = :sha256 # or :sha384, :sha512
|
|
82
|
+
config.chain_event_history_with = :sha256
|
|
83
|
+
config.anchor_event_history_with = MyIndependentAnchor.new
|
|
84
|
+
config.timestamp_receipts_with = MyRfc3161TimestampProvider.new
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Every digest stored anywhere carries its algorithm name as an `"<algorithm>:<hex>"` prefix, so
|
|
88
|
+
a future release can add an algorithm without making old events unverifiable, and an auditor
|
|
89
|
+
never has to guess which function produced a bare hex string.
|
|
90
|
+
|
|
91
|
+
### External attestations happen after commit
|
|
92
|
+
|
|
93
|
+
Timestamp and anchor providers cannot participate in the database transaction that records the
|
|
94
|
+
event and the protected host action. Clickwrap therefore calls them after commit. A provider
|
|
95
|
+
outage cannot undo a capture that already committed. Each ordinary result — `verified`,
|
|
96
|
+
`issued_unverified`, or `unavailable` — becomes an immutable attestation row. An adapter
|
|
97
|
+
exception becomes a `failed` row when the database is available and is also reported through
|
|
98
|
+
`report_after_commit_failure_with`.
|
|
99
|
+
|
|
100
|
+
There is one unavoidable crash window: a process can die after the event commits, or after a
|
|
101
|
+
provider accepts a request but before the local attestation row commits. Inventory missing
|
|
102
|
+
attempts with `clickwrap:doctor`, then reconcile them explicitly:
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
bin/rails clickwrap:integrity:attest_missing
|
|
106
|
+
bin/rails clickwrap:integrity:attest_missing SINCE=2026-08-15T00:00:00Z LIMIT=100
|
|
107
|
+
bin/rails clickwrap:integrity:attest_missing RETRY_FAILED_ATTESTATIONS=1
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
The task may call external providers. It skips events that already have a result; failed results
|
|
111
|
+
are retried only with `RETRY_FAILED_ATTESTATIONS=1`. This is at-least-once recovery, not
|
|
112
|
+
exactly-once delivery: if the provider accepted a request before the process died, a retry can
|
|
113
|
+
create another valid provider record. Timestamp adapters should use the event digest, and anchor
|
|
114
|
+
adapters the exact chain snapshot, as their provider-side idempotency input. Clickwrap preserves
|
|
115
|
+
every result rather than claiming certainty it cannot have.
|
|
116
|
+
|
|
117
|
+
Run verification continuously rather than at audit time:
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
bin/rails clickwrap:verify
|
|
121
|
+
bin/rails clickwrap:verify EVENT_ID
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
## Threat model: what happens if
|
|
127
|
+
|
|
128
|
+
Each row is a scenario the design has to survive, drawn from the product threat model. "What
|
|
129
|
+
Clickwrap does" is a statement about mechanism, not a guarantee about outcomes.
|
|
130
|
+
|
|
131
|
+
### The submitted request
|
|
132
|
+
|
|
133
|
+
| What happens if | What Clickwrap does |
|
|
134
|
+
|---|---|
|
|
135
|
+
| A crafted POST names a different policy, document, version, validity, or purpose | None of those are client inputs. The browser receives a signed presentation token and returns answers; the policy key, revision, document versions, validity window, subject binding, retention rule, and request-evidence fields are resolved server-side and rechecked inside the transaction |
|
|
136
|
+
| A stale presentation is submitted after a deploy | The manifest binds the render to the submit. A token issued against an older revision is rejected with `presentation_invalid` or `presentation_expired`, so a deploy between GET and POST never records a version the actor was not offered |
|
|
137
|
+
| An attacker swaps the actor, subject, or tenant inside a token | Each binding is checked: `presentation_actor_mismatch`, `presentation_subject_mismatch`, `presentation_tenant_mismatch`, plus `wrong_actor` / `wrong_subject` / `wrong_tenant` at verification |
|
|
138
|
+
| A double-click or a replay creates a duplicate event or a second protected action | Idempotency keys and subject locks are acquired inside the transaction. An identical key returns the original result without running the block twice, even when the first success changed the fingerprinted subject; the replay is checked against the frozen committed event. A conflicting replay fails with a stable `replay_rejected` |
|
|
139
|
+
| CSRF, session fixation, or a cross-account submission | Rails' own CSRF, session, and authentication protections remain host responsibilities. Clickwrap adds the actor/tenant/subject binding checks above on top of them |
|
|
140
|
+
|
|
141
|
+
### The data at rest
|
|
142
|
+
|
|
143
|
+
| What happens if | What Clickwrap does |
|
|
144
|
+
|---|---|
|
|
145
|
+
| A privileged application or database actor updates or deletes evidence | Events, statements, and document bindings refuse ordinary updates and destroys at the model layer. Four event columns have named mutable roles (`core_event_disposed_at`, `core_event_disposition_event_id`, `on_legal_hold`, `request_evidence_id`); reviewed core disposition also clears a fixed payload write set while atomically appending its digest-linked disposition event. Optional database hardening pushes the documented write sets into PostgreSQL. Neither stops somebody with full database access; digest/chain verification detects covered changes, and a verified outside publication narrows the gap further |
|
|
146
|
+
| A fully privileged actor rewrites the hash chain along with the events | The chain alone cannot detect this, and this guide says so rather than implying otherwise. That is what the independent anchor adapter is for, and the claim is then only as strong as the anchor |
|
|
147
|
+
| Signing or encryption keys rotate, or leak | Keys come from Rails credentials or a host key provider, and rotate with versioned key identifiers. The annex binding digest records its algorithm and a key identifier so a later reader can tell which key produced it |
|
|
148
|
+
| The server clock is wrong | Nothing here can fix that, and nothing here pretends to. `recorded_at_by_server` is labeled as the server's clock. A timestamp provider is the mechanism that adds a second, independent opinion about time |
|
|
149
|
+
| Algorithm or canonicalization changes make old events unreadable | Released formats are permanent: `KNOWN_SCHEMAS` only grows, a format change means a new explicit schema plus a verifier branch, and every previously released format keeps verifying. See [the golden-fixture policy](receipts-and-verification.md#the-golden-fixture-policy) |
|
|
150
|
+
| A mutable document source changes behind a version label | Publishing reads exact bytes and refuses to reuse a version label for different bytes. Export never fetches a live URL and calls it historical evidence. Event documents carry the digests as they stood at capture, so a document row edited in place is a detectable finding rather than a silent substitution |
|
|
151
|
+
|
|
152
|
+
### The optional request evidence
|
|
153
|
+
|
|
154
|
+
| What happens if | What Clickwrap does |
|
|
155
|
+
|---|---|
|
|
156
|
+
| A spoofed `X-Forwarded-For` or `CF-*` header is recorded as trusted evidence | The reader is host-configured and the receipt records which reader ran. A value containing a comma is refused outright rather than stored as an observation. Cloudflare-derived fields are marked host-verified only when the host explicitly asserts a verified path. See [the trusted-proxy section](request-evidence.md#trusted-proxies-and-why-requestremote_ip-alone-is-not-enough) |
|
|
157
|
+
| Client parameters masquerade as server-observed values | The form helper renders no hidden field for any of them, and submitted keys with those names are ignored. A native or API client's values stay labeled `client_reported_*` forever |
|
|
158
|
+
| IP-derived coordinates are presented as a physical location, or used to pick governing law | Every stored estimate carries `was_estimated`, its provider, its resolution time, and any accuracy metadata, and the receipt prints a sentence saying it is one provider's estimate about an address. Clickwrap never infers jurisdiction, law, or eligibility from geolocation |
|
|
159
|
+
| An opaque profile switch quietly enables new personal-data fields after an upgrade | There is no such switch. Every field is its own named setting, and enabling one never enables another category |
|
|
160
|
+
| Geolocation is retained without its provenance, or presented as several independent proofs | Provenance is not a policy choice: provider, source, estimated status, resolution time, and any accuracy or database metadata travel with any stored result and with any failure to produce one. Provider-derived country, region, city, postal code, and coordinates are one correlated estimate, not independent witnesses |
|
|
161
|
+
| A raw IP address or user-agent leaks through logs, errors, metrics, or a default export | Values are encrypted at rest by default, excluded from ordinary logs and metrics, absent from default exports, and never included in exception messages — a `fail_if_unavailable` error names the policy, the category, and the reason, never the value. OWASP's guidance on protected logging, sensitive-data discipline, and retention controls is the reference point ([Logging Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html)) *(vendor/industry guidance)* |
|
|
162
|
+
| Personal request evidence is buried so deep in immutable payloads that required deletion is impossible | It is not in the immutable payload at all. The annex is a separate table with its own schedule, and the event's canonical body excludes every annex value, so deletion cannot break verification |
|
|
163
|
+
|
|
164
|
+
### Retention and lifecycle
|
|
165
|
+
|
|
166
|
+
| What happens if | What Clickwrap does |
|
|
167
|
+
|---|---|
|
|
168
|
+
| Deleting an actor cascades and destroys the evidence | `has_clickwraps` adds no `dependent: :destroy`. The actor link nullifies and a stable pseudonymous reference remains |
|
|
169
|
+
| A retention job destroys held records, or keeps excess metadata | Every deletion path re-checks event, actor, and policy holds immediately before writing, and the applier re-derives eligibility rather than trusting the plan. Deletion nulls the value columns only, keeping the provenance that documents the deletion |
|
|
170
|
+
| A fixed-duration job deletes regulated evidence before a host event such as liquidation starts the clock | Event-based rules exist for exactly this. A host calculation returning `nil` is reported as `unresolved`, never as due. See [the retention guide](retention-and-legal-holds.md#why-a-duration-alone-is-not-enough) |
|
|
171
|
+
| An external provider succeeds but the response is lost | The outbox path records `record_provider_outcome_unknown!` as a distinct state from success and failure, and reconciliation resolves it later. A timeout never becomes a fictional success or a second debit |
|
|
172
|
+
|
|
173
|
+
### The presentation and the surrounding page
|
|
174
|
+
|
|
175
|
+
| What happens if | What Clickwrap does |
|
|
176
|
+
|---|---|
|
|
177
|
+
| A custom view removes the real control, or moves the notice below the call to action | The development linter reports `submit_control_before_clickwrap_block`, `consent_control_preselected`, and `document_link_missing`. These are heuristics that warn; they never block a render and never certify a page. See [the accessibility guide](accessibility.md) |
|
|
178
|
+
| An analytics, notification, timestamp, or anchor call fails | After-commit work is an observer, never authorization. A failure is reported through `report_after_commit_failure_with` and cannot undo evidence and domain state that already committed. Timestamp/anchor attempts additionally record an immutable result when possible; the missing-attestation inventory and explicit reconciliation task cover a process or database failure in that post-commit window |
|
|
179
|
+
| An export leaks a document, actor detail, or request secret | Operator access requires a host authorization callback plus a plain-English reason, and every access appends a `ReceiptAccess` row. Foreign event IDs return not found, so existence is not leaked |
|
|
180
|
+
|
|
181
|
+
---
|
|
182
|
+
|
|
183
|
+
## What to tell an auditor
|
|
184
|
+
|
|
185
|
+
Say the tier, say its sentence, and say what is not in it.
|
|
186
|
+
|
|
187
|
+
At baseline, that is: canonical receipts, immutable document and statement snapshots while their
|
|
188
|
+
reviewed retention periods run, versioned SHA-256 digests, an append-oriented public API with
|
|
189
|
+
named disposition transitions, and a verifier that runs without this application. It detects
|
|
190
|
+
modification of the bytes it covers. It does not establish origin, does not establish time, and
|
|
191
|
+
does not exclude fabrication by a party controlling every source.
|
|
192
|
+
|
|
193
|
+
If you need stronger rewrite detection or a third party's statement about time, the mechanisms
|
|
194
|
+
are chained history, a verified publication of exact chain snapshots, and a timestamp or
|
|
195
|
+
trust-service provider — three separate things, each claiming only what it supplies.
|
|
196
|
+
|
|
197
|
+
`bin/rails clickwrap:doctor` reports objective configuration and data facts and never prints a
|
|
198
|
+
verdict. If it printed one, it would be the least trustworthy line in the output.
|
|
199
|
+
|
|
200
|
+
---
|
|
201
|
+
|
|
202
|
+
## Sources
|
|
203
|
+
|
|
204
|
+
| Source | Class |
|
|
205
|
+
|---|---|
|
|
206
|
+
| [NIST FIPS 180-4](https://csrc.nist.gov/pubs/fips/180-4/upd1/final) — SHA-2 hash standard | Technical standard |
|
|
207
|
+
| [RFC 3161](https://www.rfc-editor.org/info/rfc3161/) — time-stamp protocol | Technical standard |
|
|
208
|
+
| [Regulation (EU) No 910/2014 (eIDAS), Article 25](https://eur-lex.europa.eu/eli/reg/2014/910/2024-05-20/eng) — legal effect of electronic signatures | Law |
|
|
209
|
+
| [RFC 8785, JSON Canonicalization Scheme](https://www.rfc-editor.org/rfc/rfc8785) | Technical standard |
|
|
210
|
+
| [OWASP Logging Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html) | Industry guidance |
|
|
211
|
+
| [GDPR Article 32](https://eur-lex.europa.eu/eli/reg/2016/679/art_32/oj/eng) — security of processing | Law |
|
|
212
|
+
| The tier ladder and every "what Clickwrap does" cell above | Product-design inference |
|
data/guides/migrating.md
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
# Migrating without inventing history
|
|
2
|
+
|
|
3
|
+
Importing is the single easiest place in this gem to manufacture evidence by accident. Every
|
|
4
|
+
field a modern capture fills in is sitting right there with an obvious, plausible value: the
|
|
5
|
+
current Terms text, today's document digest, the submit-button label from the current view, the
|
|
6
|
+
assertion sentence from the current policy, an IP address from the user's last session.
|
|
7
|
+
|
|
8
|
+
Writing any of them produces a row that is **indistinguishable from a real capture** and is, in
|
|
9
|
+
the parts that matter, a fabrication. Nobody reading the receipt in a dispute three years from
|
|
10
|
+
now would be able to tell.
|
|
11
|
+
|
|
12
|
+
So the governing rule for every import path is: historical weakness stays visible rather than
|
|
13
|
+
being laundered into modern certainty. An honest gap is worth more than a confident invention,
|
|
14
|
+
because the gap is a fact about the evidence and the invention is a lie about it.
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## What is never synthesized
|
|
19
|
+
|
|
20
|
+
| Field | Why it cannot be filled in |
|
|
21
|
+
|---|---|
|
|
22
|
+
| The presentation manifest | Nobody signed one. There is no offer to reproduce, and a synthesized manifest would be a signed description of an offer nobody made |
|
|
23
|
+
| The assertion | The sentence the old application generated or offered was not recorded. Using the current policy's wording would claim it offered text that may not have existed yet |
|
|
24
|
+
| Submit-button text | The words on the control were not recorded |
|
|
25
|
+
| IP address, browser user-agent, IP geolocation | These were never observed by this application. A later session's address is a different fact about a different request |
|
|
26
|
+
| Document bytes and digests | Only linked when the caller can point at a version that is **actually published here**. A version label alone is a claim about a label, not about content |
|
|
27
|
+
| The protected action | The old system did not bind evidence to an outcome |
|
|
28
|
+
|
|
29
|
+
Every key you list in `unknown:` is recorded twice: as a structured field on the event, and in
|
|
30
|
+
plain words inside the assertion text of each statement — which is inside the digested canonical
|
|
31
|
+
body, so the admission travels *with* the evidence rather than beside it where it can be lost.
|
|
32
|
+
|
|
33
|
+
Two more things the importer keeps apart on purpose:
|
|
34
|
+
|
|
35
|
+
- **`occurred_at` and `recorded_at_by_server`.** The first is when the old record says it
|
|
36
|
+
happened; the second is when this row was written. Collapsing them would quietly upgrade a
|
|
37
|
+
migration into a contemporaneous observation. The distance between the two is itself evidence.
|
|
38
|
+
- **Attribution.** An imported event records `imported_provider`, never `authenticated_session`
|
|
39
|
+
and never `unknown`. "This reached us from somewhere else" is a different fact from "we do not
|
|
40
|
+
know how they were attributed."
|
|
41
|
+
|
|
42
|
+
An import can never claim `web_browser` as its capture channel. The permitted values are
|
|
43
|
+
`imported_provider` and `system`, because no browser was involved and a receipt saying otherwise
|
|
44
|
+
would be wrong.
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## From `accepted_terms_at`
|
|
49
|
+
|
|
50
|
+
The typical starting point is a boolean or a timestamp column and a version string.
|
|
51
|
+
|
|
52
|
+
### Step 1: dry run one record
|
|
53
|
+
|
|
54
|
+
Always. `dry_run: true` reads everything, writes nothing, and returns the same `Result` shape as
|
|
55
|
+
the real thing, so your migration script reads identically either way.
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
user = User.find(1)
|
|
59
|
+
|
|
60
|
+
result = Clickwrap.import_legacy!(
|
|
61
|
+
:terms,
|
|
62
|
+
actor: user,
|
|
63
|
+
occurred_at: user.accepted_terms_at,
|
|
64
|
+
known: {
|
|
65
|
+
document_version: user.terms_version
|
|
66
|
+
},
|
|
67
|
+
unknown: %i[
|
|
68
|
+
exact_document_bytes
|
|
69
|
+
presentation
|
|
70
|
+
assertion
|
|
71
|
+
submit_button_text
|
|
72
|
+
request_evidence
|
|
73
|
+
],
|
|
74
|
+
because: "Imported from users.accepted_terms_at",
|
|
75
|
+
source: "users.accepted_terms_at",
|
|
76
|
+
dry_run: true
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
result.planned? # => true
|
|
80
|
+
result.written? # => false
|
|
81
|
+
result.message
|
|
82
|
+
# => "Would import terms for gid://my-app/User/1 (terms). Not recorded by the source and
|
|
83
|
+
# therefore unknown: assertion, exact_document_bytes, presentation, request_evidence,
|
|
84
|
+
# submit_button_text."
|
|
85
|
+
result.idempotency_key # => "imported_legacy:9f86d0…"
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Read that message before you read anything else. It is the sentence that will end up in the
|
|
89
|
+
receipt, and if it does not describe your legacy data accurately, your `unknown:` list is wrong.
|
|
90
|
+
|
|
91
|
+
### Step 2: check the batch shape
|
|
92
|
+
|
|
93
|
+
Run the dry run across a sample and look at three things:
|
|
94
|
+
|
|
95
|
+
```ruby
|
|
96
|
+
sample = User.where.not(accepted_terms_at: nil).limit(500)
|
|
97
|
+
|
|
98
|
+
results = sample.map do |user|
|
|
99
|
+
Clickwrap.import_legacy!(:terms, actor: user, occurred_at: user.accepted_terms_at,
|
|
100
|
+
known: { document_version: user.terms_version },
|
|
101
|
+
unknown: %i[exact_document_bytes presentation assertion
|
|
102
|
+
submit_button_text request_evidence],
|
|
103
|
+
because: "Imported from users.accepted_terms_at",
|
|
104
|
+
dry_run: true)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
results.count(&:planned?)
|
|
108
|
+
results.group_by { |r| r.known["document_version"] }.transform_values(&:size)
|
|
109
|
+
results.map(&:idempotency_key).uniq.size == results.size
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
- **How many are planned.** A record with no `occurred_at` raises rather than importing, because
|
|
113
|
+
Clickwrap will not substitute the time of the import. If the legacy row genuinely has no time,
|
|
114
|
+
do not import it as an act — record an exemption with `Clickwrap.exempt!` instead.
|
|
115
|
+
- **Which version labels appear.** Any label that is not a published Clickwrap document version
|
|
116
|
+
will import as a label with no document bytes attached. That may be fine; it should not be a
|
|
117
|
+
surprise.
|
|
118
|
+
- **That the idempotency keys are unique.** The key is derived from the policy, actor, subject,
|
|
119
|
+
tenant, `occurred_at`, and every `known:` value, so re-running the same script over the same
|
|
120
|
+
rows is a no-op rather than a second history for the same person. Change any of those inputs
|
|
121
|
+
and it is a different import — which is correct, since a different claim deserves a different
|
|
122
|
+
event rather than silently colliding with the first.
|
|
123
|
+
|
|
124
|
+
### Step 3: import for real
|
|
125
|
+
|
|
126
|
+
Drop `dry_run: true`.
|
|
127
|
+
|
|
128
|
+
```ruby
|
|
129
|
+
User.where.not(accepted_terms_at: nil).find_each do |user|
|
|
130
|
+
result = Clickwrap.import_legacy!(
|
|
131
|
+
:terms,
|
|
132
|
+
actor: user,
|
|
133
|
+
occurred_at: user.accepted_terms_at,
|
|
134
|
+
known: { document_version: user.terms_version },
|
|
135
|
+
unknown: %i[exact_document_bytes presentation assertion submit_button_text request_evidence],
|
|
136
|
+
because: "Imported from users.accepted_terms_at",
|
|
137
|
+
source: "users.accepted_terms_at"
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
Rails.logger.info(result.message)
|
|
141
|
+
end
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Re-running it is safe: an already-imported record returns `status: :already_imported` and writes
|
|
145
|
+
nothing.
|
|
146
|
+
|
|
147
|
+
### What the imported receipt looks like
|
|
148
|
+
|
|
149
|
+
The assertion says, in the receipt, exactly what this is:
|
|
150
|
+
|
|
151
|
+
> Imported from a pre-existing record: it states that this actor agreed to terms on
|
|
152
|
+
> 2023-04-11T08:22:07.000000Z. The source system's original offer wording was not recorded, so
|
|
153
|
+
> this receipt does not reproduce it. Not recorded by the source and therefore unknown: assertion,
|
|
154
|
+
> exact_document_bytes, presentation, request_evidence, submit_button_text. Imported from
|
|
155
|
+
> users.accepted_terms_at.
|
|
156
|
+
|
|
157
|
+
And the provenance block carries the structured version, including a `not_collected` list naming
|
|
158
|
+
`presentation_manifest`, `ip_address`, `browser_user_agent`, and `ip_geolocation`, plus a
|
|
159
|
+
`means` sentence saying Clickwrap did not present this content and did not observe this action.
|
|
160
|
+
|
|
161
|
+
The statement itself records `answered: false` and `answer: nil`. The action says what the old
|
|
162
|
+
system claims happened; `answered` records whether *we* have the answer, and we do not.
|
|
163
|
+
|
|
164
|
+
### Conventional `unknown:` keys
|
|
165
|
+
|
|
166
|
+
The vocabulary is open — name anything your source did not record — but these are the keys the
|
|
167
|
+
importers use, so a typo in a migration script is at least visibly a typo next to its
|
|
168
|
+
neighbours:
|
|
169
|
+
|
|
170
|
+
`exact_document_bytes`, `document_version`, `presentation`, `presentation_manifest`,
|
|
171
|
+
`assertion`, `submit_button_text`, `protected_action`, `request_evidence`, `ip_address`,
|
|
172
|
+
`browser_user_agent`, `capture_channel`, `authentication_context`.
|
|
173
|
+
|
|
174
|
+
Listing `exact_document_bytes` or `document_version` in `unknown:` also stops the importer from
|
|
175
|
+
attaching any document version at all, even if `known["document_version"]` is present. Saying
|
|
176
|
+
"we do not know the bytes" and then linking bytes anyway would contradict itself.
|
|
177
|
+
|
|
178
|
+
### Optional statements are excluded by default
|
|
179
|
+
|
|
180
|
+
If the policy has optional consent statements, `import_legacy!` imports only the **required**
|
|
181
|
+
ones unless you name statements explicitly:
|
|
182
|
+
|
|
183
|
+
```ruby
|
|
184
|
+
Clickwrap.import_legacy!(
|
|
185
|
+
:signup,
|
|
186
|
+
actor: user,
|
|
187
|
+
occurred_at: user.accepted_terms_at,
|
|
188
|
+
known: { document_version: user.terms_version },
|
|
189
|
+
unknown: %i[exact_document_bytes presentation assertion request_evidence],
|
|
190
|
+
because: "Imported from users.accepted_terms_at",
|
|
191
|
+
statements: %i[terms privacy_notice]
|
|
192
|
+
)
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
A legacy boolean column recorded one decision. Reading it as a grant of an optional consent
|
|
196
|
+
purpose it never mentioned would invent the exact thing an optional control exists to keep
|
|
197
|
+
honest.
|
|
198
|
+
|
|
199
|
+
---
|
|
200
|
+
|
|
201
|
+
## From FinePrint
|
|
202
|
+
|
|
203
|
+
[FinePrint](https://github.com/openstax/fine_print/blob/3b75fbcbcfb048ecd2f4ee7c4f0b9bd3d10f7603/README.md#L7-L25)
|
|
204
|
+
*(pinned source code)* is established Rails prior art for versioned contracts, signatures,
|
|
205
|
+
gates, and views. This importer exists because applications outgrow a question, not because they
|
|
206
|
+
chose badly. FinePrint answers "did user U sign version N of contract X?", and it answers it
|
|
207
|
+
well. What it never tried to record — the presentation, the exact wording beside the control,
|
|
208
|
+
the call to action, the request context, the domain action the signature authorized — comes
|
|
209
|
+
across as `unknown`.
|
|
210
|
+
|
|
211
|
+
The importer deliberately does **not** depend on the `fine_print` gem, require any of its files,
|
|
212
|
+
or reference any of its constants. It reads two tables through your own connection, if they are
|
|
213
|
+
there, and discovers columns rather than assuming them. A migration tool that forces you to keep
|
|
214
|
+
the gem you are migrating away from installed is a migration tool with a hostage.
|
|
215
|
+
|
|
216
|
+
### Plan first
|
|
217
|
+
|
|
218
|
+
```ruby
|
|
219
|
+
report = Clickwrap::Import::FinePrint.plan(
|
|
220
|
+
policy_key: :signup,
|
|
221
|
+
find_actor_with: ->(user_type, user_id) { User.find_by(id: user_id) },
|
|
222
|
+
map_contract_with: ->(contract) { contract["name"] == "terms_of_use" ? :terms : nil },
|
|
223
|
+
because: "Migrating from FinePrint"
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
report.possible? # => false when the tables are not in this database
|
|
227
|
+
report.signatures # => how many rows would be imported
|
|
228
|
+
report.planned # => the per-signature results
|
|
229
|
+
report.contracts # => one ContractMapping per FinePrint contract version
|
|
230
|
+
report.message
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
`report.contracts` is the part to read carefully. Each `ContractMapping` reports the FinePrint
|
|
234
|
+
contract name and version, the Clickwrap document key you mapped it to, and — crucially —
|
|
235
|
+
`published?`: whether a matching Clickwrap document version actually exists here. That is what
|
|
236
|
+
decides whether an imported event can carry real document bytes or must record the label alone.
|
|
237
|
+
The summary message names every contract that has no published match.
|
|
238
|
+
|
|
239
|
+
Two callbacks are yours to supply:
|
|
240
|
+
|
|
241
|
+
- **`find_actor_with`** receives `(user_type, user_id)` and returns the actor record, a stable
|
|
242
|
+
actor reference string, or `nil` to skip that signature.
|
|
243
|
+
- **`map_contract_with`** receives a contract row as a plain hash and returns the Clickwrap
|
|
244
|
+
document key it corresponds to. Only your application knows that its FinePrint contract named
|
|
245
|
+
`"terms_of_use"` is the document this gem calls `:terms`. Omit it and the contract's `name`
|
|
246
|
+
column is used as the key.
|
|
247
|
+
|
|
248
|
+
`contract_names:` narrows to specific contracts and `limit:` caps the number of signatures —
|
|
249
|
+
both useful for a staged migration.
|
|
250
|
+
|
|
251
|
+
If the tables are not present, the report says so in a sentence and writes nothing, rather than
|
|
252
|
+
raising. That keeps `clickwrap:doctor` and a migration checklist runnable on an application
|
|
253
|
+
that never used FinePrint.
|
|
254
|
+
|
|
255
|
+
### Then import
|
|
256
|
+
|
|
257
|
+
```ruby
|
|
258
|
+
report = Clickwrap::Import::FinePrint.import!(
|
|
259
|
+
policy_key: :signup,
|
|
260
|
+
find_actor_with: ->(_user_type, user_id) { User.find_by(id: user_id) },
|
|
261
|
+
map_contract_with: ->(contract) { contract["name"] == "terms_of_use" ? :terms : nil },
|
|
262
|
+
because: "Migrating from FinePrint"
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
report.imported.size
|
|
266
|
+
report.already_imported.size
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
Each signature becomes an `imported_legacy` event through the same path as
|
|
270
|
+
`Clickwrap.import_legacy!`, so it inherits every property above: idempotency, the two separate
|
|
271
|
+
times, the explicit unknowns, and the assertion text that says the original wording was not
|
|
272
|
+
recorded.
|
|
273
|
+
|
|
274
|
+
### What FinePrint knew and what it did not
|
|
275
|
+
|
|
276
|
+
| Recorded as `known` | Recorded as `unknown` |
|
|
277
|
+
|---|---|
|
|
278
|
+
| `source_system: "fine_print"` | `exact_document_bytes` |
|
|
279
|
+
| `fine_print_signature_id` | `presentation_manifest` |
|
|
280
|
+
| `fine_print_contract_id` | `assertion` |
|
|
281
|
+
| `contract_name`, `contract_title` | `submit_button_text` |
|
|
282
|
+
| `fine_print_contract_version` | `protected_action` |
|
|
283
|
+
| `signed_by_type`, `signed_by_id` | `request_evidence`, `ip_address`, `browser_user_agent` |
|
|
284
|
+
|
|
285
|
+
Note the deliberate naming: the version arrives as **`fine_print_contract_version`**, not
|
|
286
|
+
`document_version`. The `document_version` key is what makes the legacy importer link published
|
|
287
|
+
bytes, and a FinePrint version number is a label in another system's numbering — not a claim
|
|
288
|
+
about which bytes this application published. If you want the bytes linked, publish the matching
|
|
289
|
+
Clickwrap document version and pass `document_version` yourself.
|
|
290
|
+
|
|
291
|
+
`occurred_at` comes from the signature row's `created_at` (falling back to `updated_at`). That is
|
|
292
|
+
the best time available, and like every import it stays separate from when the Clickwrap event
|
|
293
|
+
was written down.
|
|
294
|
+
|
|
295
|
+
---
|
|
296
|
+
|
|
297
|
+
## After the import
|
|
298
|
+
|
|
299
|
+
Verify what you actually got, rather than assuming:
|
|
300
|
+
|
|
301
|
+
```ruby
|
|
302
|
+
receipt = user.clickwraps.receipts.last
|
|
303
|
+
|
|
304
|
+
receipt.event.event_type # => "imported_legacy"
|
|
305
|
+
receipt.to_h["actor"]["attribution"]["method"] # => "imported_provider"
|
|
306
|
+
receipt.to_h["request_evidence"] # => every category "not_configured"
|
|
307
|
+
receipt.verify.success?
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
And run the ordinary health check:
|
|
311
|
+
|
|
312
|
+
```bash
|
|
313
|
+
bin/rails clickwrap:doctor
|
|
314
|
+
bin/rails clickwrap:verify
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
An imported event satisfies `agreed_to?`, `acknowledged?`, and `current_for?` exactly as a live
|
|
318
|
+
capture does. That is the point of a migration: your application answered "did this person
|
|
319
|
+
agree?" with yes the day before the import, and an import that flipped the answer would force
|
|
320
|
+
every existing user back through re-acceptance. What stays different is the evidence, not the
|
|
321
|
+
answer — the event carries `imported_provider` attribution, its receipt names every unknown,
|
|
322
|
+
and the state's `current_event_id` joins to that provenance in one query. If you want migrated
|
|
323
|
+
users re-prompted when the documents move on, that is what `require_current_version: true`
|
|
324
|
+
does, deliberately, per policy. (Exemptions remain the opposite case: no human acted, so they
|
|
325
|
+
never satisfy a human-action predicate.)
|
|
326
|
+
|
|
327
|
+
---
|
|
328
|
+
|
|
329
|
+
## Sources
|
|
330
|
+
|
|
331
|
+
| Source | Class |
|
|
332
|
+
|---|---|
|
|
333
|
+
| [FinePrint README at the audited commit](https://github.com/openstax/fine_print/blob/3b75fbcbcfb048ecd2f4ee7c4f0b9bd3d10f7603/README.md#L7-L25) | Pinned source code |
|
|
334
|
+
| [FinePrint signature model at the audited commit](https://github.com/openstax/fine_print/blob/3b75fbcbcfb048ecd2f4ee7c4f0b9bd3d10f7603/app/models/fine_print/signature.rb#L1-L33) | Pinned source code |
|
|
335
|
+
| The never-synthesize rule, the `unknown:` vocabulary, and the dry-run-first workflow | Product-design inference |
|