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.
Files changed (156) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +90 -0
  3. data/CHANGELOG.md +612 -0
  4. data/README.md +830 -1204
  5. data/SECURITY.md +33 -0
  6. data/app/assets/stylesheets/clickwrap.css +241 -0
  7. data/app/controllers/clickwrap/application_controller.rb +79 -0
  8. data/app/controllers/clickwrap/captures_controller.rb +145 -0
  9. data/app/controllers/clickwrap/document_versions_controller.rb +71 -0
  10. data/app/controllers/clickwrap/receipts_controller.rb +115 -0
  11. data/app/controllers/clickwrap/withdrawals_controller.rb +60 -0
  12. data/app/helpers/clickwrap/engine_helper.rb +97 -0
  13. data/app/views/clickwrap/captures/show.html.erb +34 -0
  14. data/app/views/clickwrap/receipts/index.html.erb +38 -0
  15. data/app/views/clickwrap/receipts/show.html.erb +91 -0
  16. data/app/views/clickwrap/shared/_error_summary.html.erb +39 -0
  17. data/app/views/clickwrap/shared/_fields.html.erb +100 -0
  18. data/app/views/clickwrap/shared/_statement.html.erb +105 -0
  19. data/app/views/clickwrap/withdrawals/new.html.erb +30 -0
  20. data/config/locales/en.yml +160 -0
  21. data/config/locales/es.yml +138 -0
  22. data/config/routes.rb +41 -0
  23. data/exe/clickwrap +374 -0
  24. data/guides/README.md +30 -0
  25. data/guides/accessibility.md +249 -0
  26. data/guides/consent-and-lifecycle.md +299 -0
  27. data/guides/integrating.md +610 -0
  28. data/guides/integrity.md +212 -0
  29. data/guides/migrating.md +335 -0
  30. data/guides/naming.md +320 -0
  31. data/guides/organizations.md +320 -0
  32. data/guides/receipts-and-verification.md +415 -0
  33. data/guides/request-evidence.md +512 -0
  34. data/guides/retention-and-legal-holds.md +438 -0
  35. data/lib/clickwrap/actor_proxy.rb +147 -0
  36. data/lib/clickwrap/anonymous_actor.rb +47 -0
  37. data/lib/clickwrap/authority.rb +174 -0
  38. data/lib/clickwrap/canonical_json.rb +216 -0
  39. data/lib/clickwrap/capture/event_builder.rb +220 -0
  40. data/lib/clickwrap/capture/presentation_verifier.rb +521 -0
  41. data/lib/clickwrap/capture.rb +650 -0
  42. data/lib/clickwrap/configuration.rb +1129 -0
  43. data/lib/clickwrap/controller_helpers.rb +758 -0
  44. data/lib/clickwrap/current_state.rb +282 -0
  45. data/lib/clickwrap/digest.rb +125 -0
  46. data/lib/clickwrap/doctor.rb +418 -0
  47. data/lib/clickwrap/document_definition.rb +255 -0
  48. data/lib/clickwrap/document_renderer.rb +83 -0
  49. data/lib/clickwrap/document_renderers/markdown.rb +175 -0
  50. data/lib/clickwrap/document_renderers/markdown_rails.rb +126 -0
  51. data/lib/clickwrap/dsl/policy_builder.rb +462 -0
  52. data/lib/clickwrap/dsl/retention_builder.rb +89 -0
  53. data/lib/clickwrap/durable_commit_callback.rb +37 -0
  54. data/lib/clickwrap/engine.rb +184 -0
  55. data/lib/clickwrap/errors.rb +181 -0
  56. data/lib/clickwrap/form_builder_extensions.rb +341 -0
  57. data/lib/clickwrap/front_matter.rb +67 -0
  58. data/lib/clickwrap/identifier.rb +112 -0
  59. data/lib/clickwrap/import/external_receipt.rb +241 -0
  60. data/lib/clickwrap/import/fine_print.rb +290 -0
  61. data/lib/clickwrap/import/legacy.rb +450 -0
  62. data/lib/clickwrap/integrations/organizations_authority.rb +81 -0
  63. data/lib/clickwrap/integrity/anchor.rb +130 -0
  64. data/lib/clickwrap/integrity/attestation_reconciler.rb +114 -0
  65. data/lib/clickwrap/integrity/attestor.rb +221 -0
  66. data/lib/clickwrap/integrity/chain.rb +313 -0
  67. data/lib/clickwrap/integrity/timestamp.rb +143 -0
  68. data/lib/clickwrap/ip_geolocation/location.rb +112 -0
  69. data/lib/clickwrap/ip_geolocation/null_resolver.rb +35 -0
  70. data/lib/clickwrap/ip_geolocation/resolver.rb +97 -0
  71. data/lib/clickwrap/ip_geolocation/static_resolver.rb +107 -0
  72. data/lib/clickwrap/ip_geolocation/trackdown_resolver.rb +330 -0
  73. data/lib/clickwrap/ip_geolocation.rb +16 -0
  74. data/lib/clickwrap/lifecycle.rb +534 -0
  75. data/lib/clickwrap/linter.rb +382 -0
  76. data/lib/clickwrap/localized_text.rb +101 -0
  77. data/lib/clickwrap/macros.rb +203 -0
  78. data/lib/clickwrap/models/application_record.rb +20 -0
  79. data/lib/clickwrap/models/chain_head.rb +79 -0
  80. data/lib/clickwrap/models/concerns/has_clickwraps.rb +55 -0
  81. data/lib/clickwrap/models/disposition_plan.rb +208 -0
  82. data/lib/clickwrap/models/document.rb +46 -0
  83. data/lib/clickwrap/models/document_version.rb +163 -0
  84. data/lib/clickwrap/models/event.rb +743 -0
  85. data/lib/clickwrap/models/event_document.rb +79 -0
  86. data/lib/clickwrap/models/event_statement.rb +92 -0
  87. data/lib/clickwrap/models/external_action.rb +150 -0
  88. data/lib/clickwrap/models/integrity_attestation.rb +90 -0
  89. data/lib/clickwrap/models/legal_hold.rb +81 -0
  90. data/lib/clickwrap/models/policy_revision.rb +115 -0
  91. data/lib/clickwrap/models/presentation.rb +59 -0
  92. data/lib/clickwrap/models/receipt_access.rb +53 -0
  93. data/lib/clickwrap/models/recording_sequence.rb +21 -0
  94. data/lib/clickwrap/models/request_evidence.rb +378 -0
  95. data/lib/clickwrap/models/statement_identity_lock.rb +38 -0
  96. data/lib/clickwrap/models/statement_state.rb +130 -0
  97. data/lib/clickwrap/pending_receipt.rb +177 -0
  98. data/lib/clickwrap/policy.rb +283 -0
  99. data/lib/clickwrap/presentation_manifest.rb +210 -0
  100. data/lib/clickwrap/presenter.rb +716 -0
  101. data/lib/clickwrap/privacy.rb +419 -0
  102. data/lib/clickwrap/protected_outcome.rb +120 -0
  103. data/lib/clickwrap/receipt.rb +606 -0
  104. data/lib/clickwrap/receipt_html.rb +235 -0
  105. data/lib/clickwrap/receipt_verifier.rb +978 -0
  106. data/lib/clickwrap/reference.rb +44 -0
  107. data/lib/clickwrap/registration.rb +236 -0
  108. data/lib/clickwrap/registry.rb +54 -0
  109. data/lib/clickwrap/remediation_token.rb +155 -0
  110. data/lib/clickwrap/request_evidence_extractor.rb +590 -0
  111. data/lib/clickwrap/request_evidence_policy.rb +261 -0
  112. data/lib/clickwrap/retention/applier.rb +231 -0
  113. data/lib/clickwrap/retention/disposition.rb +221 -0
  114. data/lib/clickwrap/retention/planner.rb +502 -0
  115. data/lib/clickwrap/retention_class.rb +97 -0
  116. data/lib/clickwrap/reviewed_text.rb +28 -0
  117. data/lib/clickwrap/schema_requirements.rb +196 -0
  118. data/lib/clickwrap/services/authorize_external_action.rb +149 -0
  119. data/lib/clickwrap/services/load_policies.rb +69 -0
  120. data/lib/clickwrap/services/publish_documents.rb +251 -0
  121. data/lib/clickwrap/services/validate_policy_references.rb +166 -0
  122. data/lib/clickwrap/statement.rb +248 -0
  123. data/lib/clickwrap/subject_fingerprint.rb +28 -0
  124. data/lib/clickwrap/submission.rb +169 -0
  125. data/lib/clickwrap/system_actor.rb +31 -0
  126. data/lib/clickwrap/test_helpers.rb +676 -0
  127. data/lib/clickwrap/testing.rb +211 -0
  128. data/lib/clickwrap/trusted_proxy_configuration.rb +92 -0
  129. data/lib/clickwrap/verification.rb +504 -0
  130. data/lib/clickwrap/version.rb +12 -1
  131. data/lib/clickwrap/view_helpers.rb +190 -0
  132. data/lib/clickwrap/vocabulary.rb +294 -0
  133. data/lib/clickwrap.rb +497 -7
  134. data/lib/generators/clickwrap/document_generator.rb +164 -0
  135. data/lib/generators/clickwrap/hardening_generator.rb +177 -0
  136. data/lib/generators/clickwrap/install_generator.rb +1287 -0
  137. data/lib/generators/clickwrap/link_generator.rb +56 -0
  138. data/lib/generators/clickwrap/policy_generator.rb +118 -0
  139. data/lib/generators/clickwrap/templates/clickwrap_hardening.rb.erb +256 -0
  140. data/lib/generators/clickwrap/templates/clickwrap_policies.rb.erb +192 -0
  141. data/lib/generators/clickwrap/templates/create_clickwrap_external_action_tables.rb.erb +128 -0
  142. data/lib/generators/clickwrap/templates/create_clickwrap_integrity_tables.rb.erb +157 -0
  143. data/lib/generators/clickwrap/templates/create_clickwrap_presentation_tables.rb.erb +160 -0
  144. data/lib/generators/clickwrap/templates/create_clickwrap_request_evidence_tables.rb.erb +180 -0
  145. data/lib/generators/clickwrap/templates/create_clickwrap_retention_tables.rb.erb +174 -0
  146. data/lib/generators/clickwrap/templates/create_clickwrap_tables.rb.erb +568 -0
  147. data/lib/generators/clickwrap/templates/initializer.rb.erb +439 -0
  148. data/lib/generators/clickwrap/templates/link_clickwrap_event_migration.rb.erb +12 -0
  149. data/lib/generators/clickwrap/templates/policy.rb.erb +31 -0
  150. data/lib/generators/clickwrap/templates/policy_test.rb.erb +56 -0
  151. data/lib/generators/clickwrap/templates/privacy.md.erb +58 -0
  152. data/lib/generators/clickwrap/templates/terms.md.erb +49 -0
  153. data/lib/generators/clickwrap/upgrade_generator.rb +50 -0
  154. data/lib/generators/clickwrap/views_generator.rb +101 -0
  155. data/lib/tasks/clickwrap.rake +569 -0
  156. metadata +311 -16
@@ -0,0 +1,568 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateClickwrapTables < ActiveRecord::Migration<%= migration_version %>
4
+ def change
5
+ primary_key_type, foreign_key_type = primary_and_foreign_key_types
6
+
7
+ # ---------------------------------------------------------------------------
8
+ # clickwrap_documents / clickwrap_document_versions
9
+ #
10
+ # A logical document (:terms) is separate from its immutable versions,
11
+ # because that separation is what lets a receipt from 2026 still show the
12
+ # exact bytes bound into an accepted server offer after the file on disk has
13
+ # changed a dozen times. Publishing freezes `content` and its digest; a change means a new
14
+ # version row, never an UPDATE. `retired_at` stops future presentation
15
+ # without touching history.
16
+ #
17
+ # `rendered_content` exists because "this Markdown file existed" and "this
18
+ # rendered representation was offered" are different claims. When a source
19
+ # format is transformed for display we store both, with the renderer and
20
+ # sanitizer identity that produced it, so neither claim borrows the other's
21
+ # credibility.
22
+ # ---------------------------------------------------------------------------
23
+ create_table :clickwrap_documents, id: primary_key_type do |t|
24
+ t.string :document_key, null: false
25
+ t.string :tenant_key
26
+ t.datetime :created_at, precision: 6, null: false
27
+ end
28
+
29
+ add_index :clickwrap_documents, [ :tenant_key, :document_key ],
30
+ unique: true, name: "index_clickwrap_documents_on_tenant_and_key"
31
+
32
+ create_table :clickwrap_document_versions, id: primary_key_type do |t|
33
+ t.references :document, null: false, type: foreign_key_type, index: false,
34
+ foreign_key: { to_table: :clickwrap_documents }
35
+
36
+ t.string :version_label, null: false
37
+ t.string :locale, null: false, default: "en"
38
+ t.string :media_type, null: false, default: "text/plain"
39
+
40
+ t.send(text_column_type, :content)
41
+ t.bigint :content_byte_size
42
+ t.string :content_digest_algorithm, null: false, default: "sha256"
43
+ t.string :content_digest, null: false
44
+
45
+ t.send(text_column_type, :rendered_content)
46
+ t.string :rendered_media_type
47
+ t.string :rendered_content_digest
48
+ t.string :renderer_name
49
+ t.string :renderer_version
50
+ t.string :sanitizer_name
51
+ t.string :sanitizer_version
52
+
53
+ # Where the bytes live. `database` is the default and the only one that
54
+ # needs nothing else to stay readable; the others record a locator whose
55
+ # adapter must still return immutable bytes plus a verifiable digest.
56
+ t.string :storage_backend, null: false, default: "database"
57
+ t.string :storage_locator
58
+ t.string :source_reference
59
+
60
+ t.datetime :effective_at, precision: 6
61
+ t.datetime :published_at, precision: 6
62
+ t.datetime :retired_at, precision: 6
63
+ t.string :retired_reason
64
+
65
+ t.datetime :created_at, precision: 6, null: false
66
+ end
67
+
68
+ add_index :clickwrap_document_versions, [ :document_id, :version_label, :locale ],
69
+ unique: true, name: "index_clickwrap_document_versions_on_identity"
70
+ add_index :clickwrap_document_versions, :content_digest,
71
+ name: "index_clickwrap_document_versions_on_content_digest"
72
+
73
+ # ---------------------------------------------------------------------------
74
+ # clickwrap_policy_revisions
75
+ #
76
+ # Policies are written in Ruby, because that is reviewable in a pull request
77
+ # and deploys with the code. But an export must stay intelligible after the
78
+ # source has moved on, so the compiled snapshot is frozen here the first time
79
+ # a revision is offered or captured. The revision digest is the identity:
80
+ # change a statement's wording and you get a new revision, and old events go
81
+ # on pointing at the one they were captured under.
82
+ # ---------------------------------------------------------------------------
83
+ create_table :clickwrap_policy_revisions, id: primary_key_type do |t|
84
+ t.string :policy_key, null: false
85
+ t.string :revision_digest, null: false
86
+ t.send(json_column_type, :compiled_snapshot, null: false)
87
+ t.string :retention_class_key
88
+ t.string :canonical_schema_version, null: false
89
+ t.string :gem_version, null: false
90
+ t.datetime :compiled_at, precision: 6, null: false
91
+ t.datetime :created_at, precision: 6, null: false
92
+ end
93
+
94
+ add_index :clickwrap_policy_revisions, [ :policy_key, :revision_digest ],
95
+ unique: true, name: "index_clickwrap_policy_revisions_on_identity"
96
+
97
+ # ---------------------------------------------------------------------------
98
+ # clickwrap_events
99
+ #
100
+ # The append-oriented spine. Every act, correction, withdrawal, expiry,
101
+ # consumption, disposition, and hold is a row here, linked to what came
102
+ # before. Ordinary model updates are refused; fixed write sets exist for
103
+ # finalization, pointer nullification, annex links, holds, and reviewed
104
+ # disposition. There is no generic `updated_at` column that could obscure
105
+ # which named transition occurred.
106
+ #
107
+ # The public primary key is a ULID string: it appears verbatim in receipts
108
+ # without exposing a count of unrelated records. Chronology never relies on
109
+ # lexical ULID order. A separate database-generated sequence gives committed
110
+ # events one durable total order across actors and application processes.
111
+ #
112
+ # `recorded_at_by_server` is the evidentiary time, and it is named for
113
+ # exactly what it is: the application server's clock. `occurred_at` is
114
+ # separate and only set by importers, which know when something happened but
115
+ # were not there for it.
116
+ # ---------------------------------------------------------------------------
117
+ # Intentionally only the generated primary key: this row is ordering
118
+ # machinery, not a second event timestamp or a second evidence payload.
119
+ # This one table deliberately does NOT inherit a UUID host application's
120
+ # default primary-key type. Its generated numeric key is the private total
121
+ # order referenced by `clickwrap_events.recording_sequence`; the public
122
+ # event identifier remains the non-enumerable ULID.
123
+ create_table :clickwrap_recording_sequences, id: :primary_key do |_t|
124
+ end
125
+
126
+ create_table :clickwrap_events, id: :string, limit: 26 do |t|
127
+ t.string :event_type, null: false
128
+ t.string :policy_key, null: false
129
+ t.references :policy_revision, null: true, type: foreign_key_type, index: false,
130
+ foreign_key: { to_table: :clickwrap_policy_revisions }
131
+
132
+ # Lifecycle links. `root_event_id` points at the capture a later event
133
+ # acts on; `predecessor_event_id` points at the event it directly
134
+ # replaces. Correction, withdrawal, expiry, consumption and supersession
135
+ # all create linked rows — they never rewrite what they succeed.
136
+ t.string :root_event_id, limit: 26
137
+ t.string :predecessor_event_id, limit: 26
138
+
139
+ t.string :actor_type
140
+ t.string :actor_id
141
+ t.string :actor_reference, null: false
142
+ t.send(json_column_type, :actor_snapshot, default: json_column_default)
143
+
144
+ # Who the actor was acting for, kept as a separate fact from who they are.
145
+ t.string :represented_party_type
146
+ t.string :represented_party_id
147
+ t.string :represented_party_reference, null: false, default: ""
148
+ t.string :authority_source
149
+ t.string :authority_role
150
+ t.datetime :authority_verified_at, precision: 6
151
+ t.send(json_column_type, :authority_details, default: json_column_default)
152
+
153
+ t.string :tenant_key
154
+ t.string :subject_type
155
+ t.string :subject_id
156
+ t.string :subject_key, null: false, default: ""
157
+ t.string :subject_fingerprint
158
+
159
+ t.string :capture_channel, null: false
160
+ t.string :authentication_method
161
+ t.send(json_column_type, :authentication_context, default: json_column_default)
162
+ t.string :attribution_method, null: false, default: "unknown"
163
+
164
+ t.datetime :recorded_at_by_server, precision: 6, null: false
165
+ t.datetime :occurred_at, precision: 6
166
+ t.bigint :recording_sequence, null: false
167
+
168
+ t.string :idempotency_key
169
+ t.string :http_request_id
170
+ t.string :http_route_name
171
+
172
+ # The column is always here; the foreign key arrives with the table, in
173
+ # the --with-persisted-presentations migration. A default install writes
174
+ # nothing on GET, so it has nothing for this column to point at.
175
+ t.references :presentation, null: true, type: foreign_key_type, index: false
176
+ t.send(json_column_type, :presentation_manifest)
177
+ t.string :presentation_manifest_digest
178
+
179
+ # The optional personal request evidence lives in its own table so it can
180
+ # be deleted on its own schedule without rewriting this row. What stays
181
+ # here is a keyed digest binding the two together — described honestly as
182
+ # a retained linkable digest, never as anonymization.
183
+ t.references :request_evidence, null: true, type: foreign_key_type, index: false
184
+ t.send(json_column_type, :request_evidence_category_binding_digests,
185
+ null: false, default: json_column_default)
186
+ t.string :request_evidence_digest_algorithm
187
+ t.string :request_evidence_key_id
188
+
189
+ t.send(json_column_type, :protected_outcome)
190
+
191
+ t.string :provider_name
192
+ t.string :provider_event_id
193
+ t.send(json_column_type, :provider_receipt)
194
+ t.send(json_column_type, :provider_verification)
195
+
196
+ t.text :reason
197
+
198
+ t.string :retention_class_key
199
+ t.datetime :retain_core_event_until, precision: 6
200
+ t.string :retention_rule_name
201
+ t.datetime :core_event_disposed_at, precision: 6
202
+ t.string :core_event_disposition_event_id, limit: 26
203
+ t.boolean :on_legal_hold, null: false, default: false
204
+
205
+ t.string :chain_scope
206
+ t.bigint :chain_sequence
207
+ t.string :previous_event_digest
208
+ t.string :event_digest
209
+ t.string :digest_algorithm, null: false, default: "sha256"
210
+ t.string :canonical_schema_version, null: false
211
+
212
+ t.string :gem_version, null: false
213
+ t.string :application_version
214
+ t.string :template_version
215
+
216
+ t.datetime :created_at, precision: 6, null: false
217
+ end
218
+
219
+ # The idempotency guarantee is a database constraint, not application logic:
220
+ # a duplicate submit loses the INSERT race and gets the original receipt
221
+ # back rather than running the protected action a second time.
222
+ add_index :clickwrap_events, [ :policy_key, :idempotency_key ],
223
+ unique: true, name: "index_clickwrap_events_on_idempotency_key"
224
+ add_index :clickwrap_events, [ :chain_scope, :chain_sequence ],
225
+ unique: true, name: "index_clickwrap_events_on_chain_position"
226
+ add_index :clickwrap_events, :recording_sequence,
227
+ unique: true, name: "index_clickwrap_events_on_recording_order"
228
+ add_foreign_key :clickwrap_events, :clickwrap_recording_sequences,
229
+ column: :recording_sequence
230
+ add_index :clickwrap_events, [ :actor_reference, :policy_key ],
231
+ name: "index_clickwrap_events_on_actor_and_policy"
232
+ add_index :clickwrap_events, [ :subject_type, :subject_id ],
233
+ name: "index_clickwrap_events_on_subject"
234
+ add_index :clickwrap_events, :root_event_id, name: "index_clickwrap_events_on_root_event"
235
+ add_index :clickwrap_events, :predecessor_event_id,
236
+ name: "index_clickwrap_events_on_predecessor_event"
237
+ add_index :clickwrap_events, :presentation_id,
238
+ name: "index_clickwrap_events_on_presentation"
239
+ add_index :clickwrap_events, :request_evidence_id, unique: true,
240
+ name: "index_clickwrap_events_on_request_evidence"
241
+ add_index :clickwrap_events, :core_event_disposition_event_id,
242
+ name: "index_clickwrap_events_on_core_disposition"
243
+ add_index :clickwrap_events, :recorded_at_by_server,
244
+ name: "index_clickwrap_events_on_recorded_at_by_server"
245
+ add_index :clickwrap_events, :retain_core_event_until,
246
+ name: "index_clickwrap_events_on_retain_core_event_until"
247
+ add_index :clickwrap_events, :retention_class_key,
248
+ name: "index_clickwrap_events_on_retention_class_key"
249
+ add_index :clickwrap_events, :subject_key,
250
+ name: "index_clickwrap_events_on_subject_key"
251
+ add_clickwrap_check_constraint :clickwrap_events,
252
+ "event_type IN (#{quoted_values(%w[capture withdrawal correction supersession expiry consumption revocation renewal scope_change exemption imported_legacy external_receipt disposition legal_hold_placed legal_hold_released receipt_access provider_outcome])})",
253
+ name: "chk_clickwrap_events_event_type"
254
+ add_clickwrap_check_constraint :clickwrap_events,
255
+ "capture_channel IN (#{quoted_values(%w[web_browser native_app api_client operator background_job imported_provider system])})",
256
+ name: "chk_clickwrap_events_channel"
257
+ add_clickwrap_check_constraint :clickwrap_events,
258
+ "attribution_method IN (#{quoted_values(%w[authenticated_session account_registration public_form operator_session api_credential anonymous_identifier system_process imported_provider unknown])})",
259
+ name: "chk_clickwrap_events_attribution"
260
+
261
+ # ---------------------------------------------------------------------------
262
+ # clickwrap_event_statements
263
+ #
264
+ # One row per act inside a capture. A policy with "agree to the Terms" and
265
+ # "acknowledge the Privacy Notice" produces one event and two rows here, and
266
+ # they never merge: each keeps its own kind, its own assertion text as it was
267
+ # resolved into the accepted server offer, its own answer, and its own validity.
268
+ #
269
+ # These rows are immutable snapshots. Current state lives in the projection
270
+ # table below, which can be rebuilt from these at any time.
271
+ # ---------------------------------------------------------------------------
272
+ create_table :clickwrap_event_statements, id: primary_key_type do |t|
273
+ t.string :event_id, limit: 26, null: false
274
+ t.integer :ordinal, null: false, default: 0
275
+
276
+ t.string :statement_key, null: false
277
+ t.string :kind, null: false
278
+ t.string :action, null: false
279
+
280
+ t.text :assertion_text, null: false
281
+ t.string :assertion_locale, null: false
282
+ t.text :label_text
283
+ t.send(json_column_type, :link_labels, default: json_column_default)
284
+ t.send(json_column_type, :choices)
285
+
286
+ t.boolean :required, null: false, default: true
287
+ t.boolean :optional, null: false, default: false
288
+ t.send(json_column_type, :answer)
289
+ t.boolean :answered, null: false, default: false
290
+
291
+ t.string :purpose_key
292
+ t.string :withdrawal_path
293
+
294
+ t.datetime :valid_from, precision: 6
295
+ t.datetime :expires_at, precision: 6
296
+ t.boolean :one_time, null: false, default: false
297
+ t.send(json_column_type, :requires, default: json_array_default)
298
+
299
+ t.string :subject_fingerprint
300
+
301
+ t.datetime :created_at, precision: 6, null: false
302
+ end
303
+
304
+ add_index :clickwrap_event_statements, [ :event_id, :statement_key ],
305
+ unique: true, name: "index_clickwrap_event_statements_on_identity"
306
+ add_index :clickwrap_event_statements, [ :kind, :statement_key ],
307
+ name: "index_clickwrap_event_statements_on_kind_and_key"
308
+ add_clickwrap_check_constraint :clickwrap_event_statements,
309
+ "kind IN (#{quoted_values(%w[agreement acknowledgment consent declaration attestation authorization])})",
310
+ name: "chk_clickwrap_statements_kind"
311
+ add_clickwrap_check_constraint :clickwrap_event_statements,
312
+ "action IN (#{quoted_values(%w[agreed superseded acknowledged expired granted declined withdrawn renewed scope_changed declared corrected attested authorized consumed revoked])})",
313
+ name: "chk_clickwrap_statements_action"
314
+ # Captured controls are required XOR optional. System lifecycle statements
315
+ # describe a transition and are therefore neither; no statement may be both.
316
+ add_clickwrap_check_constraint :clickwrap_event_statements, "NOT (required AND optional)",
317
+ name: "chk_clickwrap_statements_requirement"
318
+
319
+ # ---------------------------------------------------------------------------
320
+ # clickwrap_event_documents
321
+ #
322
+ # Exactly which document versions were bound to which statement, with the
323
+ # digests as they stood at capture. Verification recomputes them, so a
324
+ # document row edited in place is detected rather than believed.
325
+ # ---------------------------------------------------------------------------
326
+ create_table :clickwrap_event_documents, id: primary_key_type do |t|
327
+ t.string :event_id, limit: 26, null: false
328
+ t.string :statement_key, null: false
329
+ t.string :document_key, null: false
330
+ t.references :document_version, null: true, type: foreign_key_type, index: false,
331
+ foreign_key: { to_table: :clickwrap_document_versions }
332
+
333
+ t.string :version_label, null: false
334
+ t.string :locale, null: false
335
+ t.string :source_media_type
336
+ t.string :source_content_digest, null: false
337
+ t.string :rendered_media_type, null: false
338
+ t.string :rendered_content_digest
339
+ t.string :renderer_name
340
+ t.string :renderer_version
341
+ t.string :sanitizer_name
342
+ t.string :sanitizer_version
343
+ t.integer :ordinal, null: false, default: 0
344
+
345
+ t.datetime :created_at, precision: 6, null: false
346
+ end
347
+
348
+ add_index :clickwrap_event_documents, [ :event_id, :statement_key, :document_key ],
349
+ unique: true, name: "index_clickwrap_event_documents_on_identity"
350
+
351
+ # ---------------------------------------------------------------------------
352
+ # clickwrap_statement_states
353
+ #
354
+ # The current-state projection: the answer to "does this person currently
355
+ # have X?" without walking the whole event history on every request. It is a
356
+ # cache of a computation over retained event payloads and can be rebuilt
357
+ # while the identity-bearing root payloads remain available.
358
+ #
359
+ # The unique index is doing real work: it is what stops two concurrent
360
+ # submits from producing two live grants or two usable authorizations for
361
+ # the same actor and subject. The `_key` columns exist for that index —
362
+ # NULLs do not collide in a unique index on most adapters, so the empty
363
+ # string stands in for "no tenant" and "no subject".
364
+ # ---------------------------------------------------------------------------
365
+ create_table :clickwrap_statement_states, id: primary_key_type do |t|
366
+ # A digest of the six identity columns below, carrying the unique index.
367
+ #
368
+ # This is a portability decision with teeth. Those six hold a policy key,
369
+ # a statement key, a GlobalID-shaped actor reference, a tenant key, a
370
+ # subject key, and a represented-party reference; indexing the strings
371
+ # directly can exceed MySQL's 3072-byte index limit
372
+ # under utf8mb4 even at a reduced length, so a composite unique index over
373
+ # them cannot be created there at all. The guarantee this index provides is
374
+ # the one that keeps a double submit from becoming two debits, so it has to
375
+ # exist on every supported database — hence one fixed-width column, with an
376
+ # ordinary lookup index over the readable ones beside it.
377
+ t.string :identity_digest, null: false, limit: 71
378
+
379
+ t.string :policy_key, null: false
380
+ t.string :statement_key, null: false
381
+ t.string :kind, null: false
382
+ t.string :purpose_key
383
+
384
+ t.string :actor_type
385
+ t.string :actor_id
386
+ t.string :actor_reference, null: false
387
+ t.string :tenant_key, null: false, default: ""
388
+ t.string :subject_type
389
+ t.string :subject_id
390
+ t.string :subject_key, null: false, default: ""
391
+ t.string :represented_party_reference, null: false, default: ""
392
+ t.string :subject_fingerprint
393
+
394
+ t.string :state, null: false
395
+ t.string :current_action, null: false
396
+ t.string :current_event_id, limit: 26, null: false
397
+ t.string :root_event_id, limit: 26
398
+ t.references :policy_revision, null: true, type: foreign_key_type, index: false,
399
+ foreign_key: { to_table: :clickwrap_policy_revisions }
400
+
401
+ t.datetime :effective_at, precision: 6, null: false
402
+ t.datetime :expires_at, precision: 6
403
+ t.datetime :withdrawn_at, precision: 6
404
+ t.datetime :superseded_at, precision: 6
405
+ t.datetime :consumed_at, precision: 6
406
+ t.datetime :revoked_at, precision: 6
407
+ t.datetime :corrected_at, precision: 6
408
+ t.boolean :one_time, null: false, default: false
409
+
410
+ t.send(json_column_type, :document_version_ids, default: json_array_default)
411
+
412
+ t.timestamps precision: 6
413
+ end
414
+
415
+ add_index :clickwrap_statement_states, :identity_digest,
416
+ unique: true, name: "index_clickwrap_statement_states_on_identity"
417
+ add_index :clickwrap_statement_states,
418
+ [ :policy_key, :statement_key ],
419
+ name: "index_clickwrap_statement_states_on_policy_and_statement"
420
+ add_index :clickwrap_statement_states, [ :actor_reference, :state ],
421
+ name: "index_clickwrap_statement_states_on_actor_and_state"
422
+ add_index :clickwrap_statement_states, :expires_at,
423
+ name: "index_clickwrap_statement_states_on_expires_at"
424
+ add_index :clickwrap_statement_states, :current_event_id,
425
+ name: "index_clickwrap_statement_states_on_current_event"
426
+ add_index :clickwrap_statement_states, :root_event_id,
427
+ name: "index_clickwrap_statement_states_on_root_event"
428
+ add_clickwrap_check_constraint :clickwrap_statement_states,
429
+ "state IN (#{quoted_values(%w[active declined withdrawn expired superseded consumed revoked corrected exempted])})",
430
+ name: "chk_clickwrap_states_state"
431
+
432
+ # A row exists before or after the first StatementState for an identity, so
433
+ # concurrent first-time authorizations have something portable to lock.
434
+ # These are coordination rows, not evidence, and contain only the canonical
435
+ # identity digest already used by StatementState's unique index.
436
+ create_table :clickwrap_statement_identity_locks, id: primary_key_type do |t|
437
+ t.string :identity_digest, null: false, limit: 71
438
+ t.datetime :created_at, precision: 6, null: false
439
+ end
440
+
441
+ add_index :clickwrap_statement_identity_locks, :identity_digest, unique: true,
442
+ name: "index_clickwrap_statement_identity_locks_on_identity"
443
+
444
+ # ---------------------------------------------------------------------------
445
+ # clickwrap_receipt_accesses
446
+ #
447
+ # Who read what, and why. Unredacted request evidence needs host
448
+ # authorization plus a human-readable reason, and asking for it appends a
449
+ # row here. An access log that nobody can read is not much of a control, so
450
+ # this table is plain and queryable.
451
+ # ---------------------------------------------------------------------------
452
+ create_table :clickwrap_receipt_accesses, id: primary_key_type do |t|
453
+ t.string :event_id, limit: 26, null: false
454
+ t.string :requested_by_reference
455
+ t.text :reason
456
+ t.send(json_column_type, :included_fields, null: false)
457
+ t.string :access_channel, null: false, default: "api"
458
+ t.datetime :accessed_at, precision: 6, null: false
459
+ t.datetime :created_at, precision: 6, null: false
460
+ end
461
+
462
+ add_index :clickwrap_receipt_accesses, [ :event_id, :accessed_at ],
463
+ name: "index_clickwrap_receipt_accesses_on_event"
464
+
465
+ # Every non-polymorphic evidence link is backed by the database, including
466
+ # lifecycle links and the tables whose ids are ULID strings rather than the
467
+ # host application's primary-key type. Model callbacks make ordinary writes
468
+ # readable; these constraints keep console SQL, bulk imports, and future code
469
+ # from manufacturing orphaned evidence by accident.
470
+ add_clickwrap_foreign_key :clickwrap_events, :clickwrap_events,
471
+ column: :root_event_id, name: "fk_clickwrap_events_root"
472
+ add_clickwrap_foreign_key :clickwrap_events, :clickwrap_events,
473
+ column: :predecessor_event_id, name: "fk_clickwrap_events_predecessor"
474
+ add_clickwrap_foreign_key :clickwrap_events, :clickwrap_events,
475
+ column: :core_event_disposition_event_id, name: "fk_clickwrap_events_disposition"
476
+
477
+ add_clickwrap_foreign_key :clickwrap_event_statements, :clickwrap_events,
478
+ column: :event_id, name: "fk_clickwrap_statements_event"
479
+ add_clickwrap_foreign_key :clickwrap_event_documents, :clickwrap_events,
480
+ column: :event_id, name: "fk_clickwrap_documents_event"
481
+ add_clickwrap_foreign_key :clickwrap_statement_states, :clickwrap_events,
482
+ column: :current_event_id, name: "fk_clickwrap_states_current_event"
483
+ add_clickwrap_foreign_key :clickwrap_statement_states, :clickwrap_events,
484
+ column: :root_event_id, name: "fk_clickwrap_states_root_event"
485
+ add_clickwrap_foreign_key :clickwrap_receipt_accesses, :clickwrap_events,
486
+ column: :event_id, name: "fk_clickwrap_receipt_accesses_event"
487
+
488
+ # The database rejects contradictory statement requirement flags and values
489
+ # outside this release's frozen vocabularies. A future release that adds a
490
+ # lifecycle value ships an upgrade migration that widens the matching check;
491
+ # raw SQL must not be able to manufacture evidence the model cannot read.
492
+ end
493
+
494
+ private
495
+
496
+ # Honor the host's configured primary key type (uuid vs bigint). Reads the
497
+ # same setting `rails g model` uses, so an app generated with
498
+ # `config.generators { |g| g.orm :active_record, primary_key_type: :uuid }`
499
+ # gets uuid clickwrap tables and uuid foreign keys, automatically.
500
+ #
501
+ # Note that clickwrap_events keeps a ULID string key regardless: its id is
502
+ # quoted verbatim in receipts and exports, so it has to be stable, sortable,
503
+ # and identical in every host.
504
+ def primary_and_foreign_key_types
505
+ config = Rails.configuration.generators
506
+ setting = config.options[config.orm][:primary_key_type]
507
+ primary_key_type = setting || :primary_key
508
+ foreign_key_type = setting || :bigint
509
+ [ primary_key_type, foreign_key_type ]
510
+ end
511
+
512
+ def json_column_type
513
+ return :jsonb if connection.adapter_name.downcase.match?(/postg/) # postgresql, postgis
514
+
515
+ :json
516
+ end
517
+
518
+ # MySQL 8+ doesn't allow default values on JSON columns. Returns an empty-hash
519
+ # default for SQLite/PostgreSQL, nil for MySQL. The models handle nil
520
+ # gracefully by defaulting to {} in their accessors.
521
+ def json_column_default
522
+ return nil if connection.adapter_name.downcase.match?(/mysql|trilogy/)
523
+
524
+ {}
525
+ end
526
+
527
+ # Same MySQL caveat as `json_column_default`, but for list-shaped columns.
528
+ def json_array_default
529
+ return nil if connection.adapter_name.downcase.match?(/mysql|trilogy/)
530
+
531
+ []
532
+ end
533
+
534
+ # Legal documents are routinely longer than MySQL's 64 KB TEXT limit, and a
535
+ # silently truncated agreement is the worst possible failure for this gem.
536
+ # `:mediumtext` maps to MEDIUMTEXT on MySQL (16 MB) and to ordinary TEXT
537
+ # everywhere else.
538
+ def text_column_type
539
+ return :mediumtext if connection.adapter_name.downcase.match?(/mysql|trilogy/)
540
+
541
+ :text
542
+ end
543
+
544
+ def quoted_values(values)
545
+ values.map { |value| connection.quote(value) }.join(", ")
546
+ end
547
+
548
+ # SQLite implements both foreign keys and check constraints by rebuilding a
549
+ # table. During a long install migration, Active Record can otherwise rebuild
550
+ # from a schema-cache entry captured before the indexes/columns immediately
551
+ # above were added. Refreshing around every rebuild keeps the SQLite result
552
+ # identical to PostgreSQL/MySQL instead of quietly resurrecting stale shape.
553
+ def add_clickwrap_check_constraint(table, expression, **options)
554
+ refresh_clickwrap_table_schema!(table)
555
+ add_check_constraint(table, expression, **options)
556
+ refresh_clickwrap_table_schema!(table)
557
+ end
558
+
559
+ def add_clickwrap_foreign_key(from_table, to_table, **options)
560
+ refresh_clickwrap_table_schema!(from_table)
561
+ add_foreign_key(from_table, to_table, **options)
562
+ refresh_clickwrap_table_schema!(from_table)
563
+ end
564
+
565
+ def refresh_clickwrap_table_schema!(table)
566
+ connection.schema_cache.clear_data_source_cache!(table.to_s)
567
+ end
568
+ end