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,978 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The standalone verifier's dependency list is the feature. It requires JSON,
4
+ # the canonicalizer, the digest helpers, the error classes, and the version
5
+ # constants — and nothing else. No Rails, no ActiveRecord, no database, no
6
+ # engine, no host application, no policy source.
7
+ #
8
+ # That is what makes an exported receipt worth exporting. A verifier that had
9
+ # to boot the application which produced the evidence would only ever be able
10
+ # to tell you that the application agrees with itself. This one runs in a bare
11
+ # `ruby -r` on a machine that has never heard of your product, five years from
12
+ # now, from a file and a folder of documents.
13
+ #
14
+ # `require_relative` rather than `require` on purpose: the four files below sit
15
+ # next to this one in the gem, and resolving them by path means the verifier
16
+ # works whether or not anyone remembered to put `lib` on the load path.
17
+ require "json"
18
+
19
+ require_relative "errors"
20
+ require_relative "version"
21
+ require_relative "canonical_json"
22
+ require_relative "digest"
23
+
24
+ module Clickwrap
25
+ # Verifies an exported canonical receipt.
26
+ #
27
+ # ===========================================================================
28
+ # WHAT A SUCCESSFUL VERIFICATION HERE DOES AND DOES NOT ESTABLISH.
29
+ #
30
+ # It DOES establish:
31
+ #
32
+ # * that the file is well-formed JSON in a receipt schema this verifier
33
+ # knows by name;
34
+ # * that its bytes are canonical under RFC 8785, so two readers digest the
35
+ # same thing;
36
+ # * that the digest the receipt carries matches the receipt body it
37
+ # travels with, so accidental or ordinary modification of those bytes is
38
+ # detected;
39
+ # * that each document file you supplied hashes to the digest the receipt
40
+ # recorded for it, so the bundle is internally consistent; and
41
+ # * that any chain links present are consistent with each other.
42
+ #
43
+ # It does NOT establish:
44
+ #
45
+ # * that the receipt was not fabricated. A self-contained file verifying
46
+ # against itself proves internal consistency and nothing about origin. A
47
+ # party who controlled the application, the database, and the export
48
+ # could have produced every byte in it, including the digest, and this
49
+ # verifier would say "verified" — because the only thing it can compare
50
+ # the bytes against is the bytes;
51
+ # * WHEN anything happened. `recorded_at_by_server` is a time an
52
+ # application server wrote down. It is not attested by anyone else;
53
+ # * WHO acted. An actor reference identifies a record in someone's
54
+ # database, not a person;
55
+ # * that any of it is legally sufficient, adequately presented, or
56
+ # admissible anywhere. That is not a property of a file.
57
+ #
58
+ # Origin and time evidence come from things this verifier cannot supply on
59
+ # its own: an independent publication holding the exact event-chain snapshot somewhere the
60
+ # database operator does not control, an RFC 3161 or trust-service timestamp,
61
+ # or a provider's own signed receipt. Where those exist, they are reported as
62
+ # exactly the assurance they supply, and where they do not, their absence is
63
+ # visible rather than papered over by a green check mark.
64
+ # ===========================================================================
65
+ module ReceiptVerifier
66
+ # Every receipt schema this verifier can read.
67
+ #
68
+ # THIS LIST ONLY EVER GROWS. A released evidence format is permanent: a new
69
+ # gem version may stop *creating* an old schema, but it must never stop
70
+ # *verifying* one, because the receipts already exported under it are out
71
+ # in the world and their whole value is that they still check out years
72
+ # later. A format change means a new entry here and a new branch of
73
+ # verification logic — never a silent reinterpretation of an old name.
74
+ KNOWN_SCHEMAS = ["clickwrap.receipt.v1"].freeze
75
+
76
+ # The key that holds the digest, and which is therefore excluded from the
77
+ # bytes the digest covers. See `body_covered_by_digest` for the precise
78
+ # rule and the reason it has to work this way.
79
+ INTEGRITY_KEY = "integrity"
80
+
81
+ # `receipt_digest`, not `event_digest`. The two are different values and
82
+ # answer different questions: `event_digest` was computed over the embedded
83
+ # event's canonical body when the event was written; `receipt_digest` covers
84
+ # the entire exported projection. The standalone verifier checks both (or a
85
+ # documented disposition in place of a removed event payload), and excludes
86
+ # only this self-referential receipt field when checking the latter.
87
+ DIGEST_KEY = "receipt_digest"
88
+
89
+ # One thing that was looked at, and what was found. `passed` is nil for a
90
+ # check that could not be run — a document whose bytes were not supplied is
91
+ # neither a pass nor a failure, and collapsing the three states into two is
92
+ # how "we did not look" becomes "we looked and it was fine".
93
+ Check = Data.define(:name, :passed, :detail) do
94
+ def passed? = passed == true
95
+ def failed? = passed == false
96
+ def skipped? = passed.nil?
97
+ def to_s = "#{status_word} #{name}: #{detail}"
98
+
99
+ def status_word
100
+ return "ok" if passed?
101
+ return "FAILED" if failed?
102
+
103
+ "skipped"
104
+ end
105
+ end
106
+
107
+ # The result object. `success?` is true only when every check that ran
108
+ # passed; a skipped check never makes a result succeed and never makes it
109
+ # fail, it just stays visible in `checks`.
110
+ class Result
111
+ attr_reader :schema, :checks
112
+
113
+ def initialize(schema:, checks:)
114
+ @schema = schema
115
+ @checks = checks.freeze
116
+ freeze
117
+ end
118
+
119
+ def verified? = failures.empty? && skipped.empty?
120
+ alias success? verified?
121
+
122
+ def failed? = failures.any?
123
+ def incomplete? = failures.empty? && skipped.any?
124
+
125
+ def status
126
+ if failed?
127
+ "failed"
128
+ else
129
+ (incomplete? ? "incomplete" : "verified")
130
+ end
131
+ end
132
+
133
+ def failures = checks.select(&:failed?)
134
+ def skipped = checks.select(&:skipped?)
135
+ def passed = checks.select(&:passed?)
136
+
137
+ def to_h
138
+ {
139
+ "success" => success?,
140
+ "status" => status,
141
+ "schema" => schema,
142
+ "verifier_version" => Clickwrap::VERIFIER_VERSION,
143
+ "checks" => checks.map do |check|
144
+ { "name" => check.name, "passed" => check.passed, "detail" => check.detail }
145
+ end
146
+ }
147
+ end
148
+
149
+ def to_s
150
+ lines = ["#{status.upcase} (#{schema || "unknown schema"})"]
151
+ lines.concat(checks.map { |check| " #{check}" })
152
+ lines << " note: #{PROVES}"
153
+ lines.join("\n")
154
+ end
155
+
156
+ def inspect = "#<Clickwrap::ReceiptVerifier::Result #{status}>"
157
+ end
158
+
159
+ # Printed with every result, so nobody has to go looking for the caveat.
160
+ PROVES =
161
+ "A successful verification shows this receipt is internally consistent and its bytes have " \
162
+ "not changed since the digest was taken. It does not show who produced it, when, or that " \
163
+ "a party controlling every source could not have fabricated the whole file. Independent " \
164
+ "anchors and provider signatures are what add origin and time evidence."
165
+
166
+ class << self
167
+ # Verifies `canonical_json_string`.
168
+ #
169
+ # `documents:` maps a document key (or "key@version") to the exact bytes
170
+ # of that document. Any document not supplied is reported `not_supplied`
171
+ # rather than assumed fine.
172
+ def verify(canonical_json_string, documents: {})
173
+ checks = []
174
+ schema = nil
175
+
176
+ body = parse(canonical_json_string, checks)
177
+ return Result.new(schema: nil, checks: checks) if body.nil?
178
+
179
+ schema = body["schema"]
180
+ return Result.new(schema: schema, checks: checks) unless check_schema(schema, checks)
181
+
182
+ check_canonical_bytes(canonical_json_string, body, checks)
183
+ check_receipt_digest(body, checks)
184
+ check_event_digest(body, checks)
185
+ check_lifecycle_successors(body, checks)
186
+ check_integrity_attestations(body, checks)
187
+ check_documents(body, documents, checks)
188
+ check_chain_linkage(body, checks)
189
+
190
+ Result.new(schema: schema, checks: checks)
191
+ rescue StandardError => error
192
+ checks << Check.new(
193
+ name: "verifier_input",
194
+ passed: false,
195
+ detail: "The receipt could not be verified safely because #{error.class}: #{error.message}"
196
+ )
197
+ Result.new(schema: schema, checks: checks)
198
+ end
199
+
200
+ # Same, but raises rather than returning a failed result. Useful in a
201
+ # script where a bad receipt should stop the run.
202
+ #
203
+ # An unknown schema gets its own error class, because it is a different
204
+ # problem with a different fix: the receipt is probably fine and this
205
+ # verifier is too old to read it.
206
+ def verify!(canonical_json_string, documents: {})
207
+ result = verify(canonical_json_string, documents: documents)
208
+ return result if result.success?
209
+
210
+ unknown = result.failures.find { |check| check.name == "known_schema" }
211
+ raise UnknownReceiptSchema, unknown.detail if unknown
212
+
213
+ raise ReceiptInvalid, result.to_s
214
+ end
215
+
216
+ def known_schema?(schema) = KNOWN_SCHEMAS.include?(schema.to_s)
217
+
218
+ # The exact bytes a receipt's `integrity.receipt_digest` covers: the whole
219
+ # receipt with only that self-referential field removed. Every other
220
+ # integrity claim—including event digest, chain position, tier, and claim
221
+ # sentence—is covered and cannot be edited for free.
222
+ #
223
+ # The exclusion is not a convenience. A digest cannot cover itself: the
224
+ # moment the digest is written into the object, the object's bytes change
225
+ # and the digest no longer matches them, and no amount of recomputation
226
+ # converges. So the digest is taken over the body before this one field is
227
+ # attached, and verification reproduces that by removing the field again.
228
+ #
229
+ # The exclusion is precisely `integrity.receipt_digest` and nothing else.
230
+ # Every other key — including the rest of `integrity`,
231
+ # `verifier_instructions`, and any host
232
+ # `x_`-prefixed extension — is inside the digest. A key that were excluded
233
+ # without being named here would be a key anyone could edit freely, which
234
+ # is the opposite of the point.
235
+ def body_covered_by_digest(body)
236
+ covered = deep_copy(body)
237
+ integrity = covered[INTEGRITY_KEY]
238
+ integrity.delete(DIGEST_KEY) if integrity.is_a?(Hash)
239
+ covered
240
+ end
241
+
242
+ private
243
+
244
+ def parse(text, checks)
245
+ if text.nil? || text.to_s.empty?
246
+ checks << Check.new(name: "json_parses", passed: false, detail: "The receipt was empty.")
247
+ return nil
248
+ end
249
+
250
+ body = JSON.parse(text.to_s, object_class: UniqueKeyHash)
251
+
252
+ unless body.is_a?(Hash)
253
+ checks << Check.new(name: "json_parses", passed: false,
254
+ detail: "A receipt is a JSON object; this one is a #{body.class}.")
255
+ return nil
256
+ end
257
+
258
+ checks << Check.new(name: "json_parses", passed: true, detail: "Parsed as a JSON object.")
259
+ body
260
+ rescue JSON::ParserError, DuplicateJsonKey => error
261
+ checks << Check.new(name: "json_parses", passed: false,
262
+ detail: "The receipt is not valid JSON: #{error.message}")
263
+ nil
264
+ end
265
+
266
+ # An unknown schema fails honestly and stops. It is NOT read on a
267
+ # best-effort basis: guessing at the meaning of a format this verifier
268
+ # has never seen would produce a confident answer about a document it
269
+ # does not understand, which is worse than no answer at all.
270
+ def check_schema(schema, checks)
271
+ if known_schema?(schema)
272
+ checks << Check.new(name: "known_schema", passed: true,
273
+ detail: "Schema #{schema} is one this verifier knows.")
274
+ return true
275
+ end
276
+
277
+ checks << Check.new(
278
+ name: "known_schema", passed: false,
279
+ detail: "Schema #{schema.inspect} is not one this verifier knows " \
280
+ "(#{KNOWN_SCHEMAS.join(", ")}). Verification stopped rather than guessing at " \
281
+ "the meaning of a newer format. Use a Clickwrap release that lists this schema."
282
+ )
283
+ false
284
+ end
285
+
286
+ def check_canonical_bytes(text, body, checks)
287
+ canonical = CanonicalJson.generate(body)
288
+
289
+ if canonical == text.to_s
290
+ checks << Check.new(name: "canonical_bytes", passed: true,
291
+ detail: "The bytes are already canonical under RFC 8785.")
292
+ return
293
+ end
294
+
295
+ checks << Check.new(
296
+ name: "canonical_bytes", passed: false,
297
+ detail: "The supplied bytes are not in canonical form; they were re-canonicalized per " \
298
+ "RFC 8785 for the remaining checks, but this file is not the exact canonical " \
299
+ "receipt artifact Clickwrap exports."
300
+ )
301
+ rescue CanonicalJson::SerializationError => error
302
+ checks << Check.new(name: "canonical_bytes", passed: false,
303
+ detail: "The receipt cannot be canonicalized: #{error.message}")
304
+ end
305
+
306
+ def check_receipt_digest(body, checks)
307
+ integrity = body[INTEGRITY_KEY]
308
+
309
+ unless integrity.is_a?(Hash) && integrity[DIGEST_KEY].to_s != ""
310
+ checks << Check.new(
311
+ name: "receipt_digest", passed: false,
312
+ detail: "This receipt carries no integrity.#{DIGEST_KEY}, so there is nothing to " \
313
+ "check the body against."
314
+ )
315
+ return
316
+ end
317
+
318
+ recorded = integrity[DIGEST_KEY].to_s
319
+ algorithm = Digest.algorithm_of(recorded)
320
+
321
+ unless algorithm && Digest.supported?(algorithm)
322
+ checks << Check.new(
323
+ name: "receipt_digest", passed: false,
324
+ detail: "integrity.#{DIGEST_KEY} is #{recorded.inspect}, which does not name a " \
325
+ "digest algorithm this verifier supports (#{Digest::SUPPORTED_ALGORITHMS.keys.join(", ")})."
326
+ )
327
+ return
328
+ end
329
+
330
+ computed = Digest.digest_canonical(body_covered_by_digest(body), algorithm: algorithm)
331
+
332
+ checks << if Digest.secure_compare?(computed, recorded)
333
+ Check.new(
334
+ name: "receipt_digest", passed: true,
335
+ detail: "The recorded #{algorithm} digest matches the canonicalized receipt body " \
336
+ "with only integrity.#{DIGEST_KEY} excluded."
337
+ )
338
+ else
339
+ Check.new(
340
+ name: "receipt_digest", passed: false,
341
+ detail: "The recorded digest #{recorded} does not match the body. Computed #{computed} " \
342
+ "over the canonicalized receipt with only integrity.#{DIGEST_KEY} excluded. Something " \
343
+ "about these bytes changed after the digest was taken."
344
+ )
345
+ end
346
+ end
347
+
348
+ def check_event_digest(body, checks)
349
+ integrity = body[INTEGRITY_KEY]
350
+ event_body = body["event"]
351
+ recorded = integrity.is_a?(Hash) ? integrity["event_digest"].to_s : ""
352
+
353
+ unless event_body.is_a?(Hash) && !recorded.empty?
354
+ checks << Check.new(
355
+ name: "event_digest", passed: false,
356
+ detail: "The receipt must carry both event and integrity.event_digest so the " \
357
+ "application's recorded event can be re-derived independently."
358
+ )
359
+ return
360
+ end
361
+
362
+ algorithm = Digest.algorithm_of(recorded)
363
+ unless algorithm && Digest.supported?(algorithm)
364
+ checks << Check.new(name: "event_digest", passed: false,
365
+ detail: "integrity.event_digest does not use a supported algorithm.")
366
+ return
367
+ end
368
+
369
+ if body.dig("retention", "core_event_disposed_at").to_s != ""
370
+ check_documented_core_disposition(body, event_body, recorded, checks)
371
+ return
372
+ end
373
+
374
+ computed = Digest.digest_canonical(event_body, algorithm: algorithm)
375
+ mismatches = event_projection_mismatches(body, event_body)
376
+ projection_matches = mismatches.empty?
377
+
378
+ checks << if Digest.secure_compare?(computed, recorded) && projection_matches
379
+ Check.new(name: "event_digest", passed: true,
380
+ detail: "The embedded canonical event and receipt projection match " \
381
+ "the recorded #{algorithm} event digest.")
382
+ else
383
+ Check.new(
384
+ name: "event_digest",
385
+ passed: false,
386
+ detail: "The embedded event digest or its receipt projection does not match " \
387
+ "the recorded event evidence. Mismatched projections: " \
388
+ "#{mismatches.join(", ").empty? ? "(embedded digest)" : mismatches.join(", ")}"
389
+ )
390
+ end
391
+ end
392
+
393
+ # A retention run deliberately removes the original event payload, so a
394
+ # later export cannot rederive its original digest without defeating the
395
+ # deletion. That is neither a successful digest check nor evidence of
396
+ # tampering. It is an explicit incomplete check, but only after the
397
+ # retained tombstone and the independently digest-checked disposition
398
+ # successor agree on exactly what was disposed, when, and which original
399
+ # digest remains as the historical anchor.
400
+ def check_documented_core_disposition(body, event_body, recorded_digest, checks)
401
+ disposed_at = body.dig("retention", "core_event_disposed_at").to_s
402
+ event_id = body["event_id"].to_s
403
+ problems = event_projection_mismatches(body, event_body)
404
+ .map { |field| "retained receipt projection differs at #{field}" }
405
+
406
+ problems.concat(disposed_tombstone_problems(body, event_body))
407
+
408
+ successors = Array(body.dig("lifecycle", "successors"))
409
+ dispositions = successors.select do |successor|
410
+ successor.is_a?(Hash) &&
411
+ successor.dig("event", "protected_outcome", "core_event_disposition").is_a?(Hash)
412
+ end
413
+
414
+ if dispositions.one?
415
+ successor_event = dispositions.first["event"]
416
+ facts = successor_event.dig("protected_outcome", "core_event_disposition")
417
+ problems << "disposition names a different event" unless facts["event_id"].to_s == event_id
418
+ problems << "disposition names a different original event digest" unless
419
+ facts["original_event_digest"].to_s == recorded_digest
420
+ problems << "disposition records a different disposal time" unless facts["disposed_at"].to_s == disposed_at
421
+ unless successor_event["event_type"] == "disposition"
422
+ problems << "disposition successor has the wrong event type"
423
+ end
424
+
425
+ linked = successor_event["root_event_id"].to_s == event_id ||
426
+ successor_event["predecessor_event_id"].to_s == event_id
427
+ problems << "disposition successor is not linked to the disposed event" unless linked
428
+ else
429
+ problems << if dispositions.empty?
430
+ "no core-event disposition successor is present"
431
+ else
432
+ "more than one core-event disposition successor is present"
433
+ end
434
+ end
435
+
436
+ checks << if problems.empty?
437
+ Check.new(
438
+ name: "event_digest",
439
+ passed: nil,
440
+ detail: "The original core event payload was lawfully disposed at #{disposed_at}. " \
441
+ "The retained tombstone and documented disposition identify its original " \
442
+ "digest #{recorded_digest}, but that digest cannot be re-derived after the " \
443
+ "covered payload was deleted."
444
+ )
445
+ else
446
+ Check.new(
447
+ name: "event_digest",
448
+ passed: false,
449
+ detail: "The receipt claims a core-event disposition, but its retained evidence is " \
450
+ "inconsistent: #{problems.join("; ")}."
451
+ )
452
+ end
453
+ end
454
+
455
+ def disposed_tombstone_problems(body, event_body)
456
+ problems = []
457
+ problems << "receipt acts remain after disposition" unless body["acts"] == []
458
+ problems << "receipt documents remain after disposition" unless body["documents"] == []
459
+ problems << "embedded event acts remain after disposition" unless event_body["acts"] == []
460
+ problems << "embedded event documents remain after disposition" unless event_body["documents"] == []
461
+ problems << "receipt actor reference remains after disposition" unless body.dig("actor",
462
+ "reference").to_s.empty?
463
+ problems << "embedded actor reference remains after disposition" unless
464
+ event_body.dig("actor", "reference").to_s.empty?
465
+ problems << "receipt presentation remains after disposition" unless body["presentation"].nil?
466
+ problems << "embedded presentation remains after disposition" unless event_body["presentation"].nil?
467
+ problems << "receipt protected outcome remains after disposition" unless body["outcome"].nil?
468
+ problems << "embedded protected outcome remains after disposition" unless event_body["protected_outcome"].nil?
469
+ problems
470
+ end
471
+
472
+ def check_lifecycle_successors(body, checks)
473
+ successors = body.dig("lifecycle", "successors")
474
+ return if successors.nil?
475
+
476
+ unless successors.is_a?(Array)
477
+ checks << Check.new(name: "lifecycle_successors", passed: false,
478
+ detail: "lifecycle.successors must be an array.")
479
+ return
480
+ end
481
+
482
+ seen = {}
483
+ successors.each_with_index do |successor, index|
484
+ check_lifecycle_successor(body, successor, index, seen, checks)
485
+ end
486
+ end
487
+
488
+ def check_lifecycle_successor(root_body, successor, index, seen, checks)
489
+ name = "lifecycle_successor:#{index}"
490
+ unless successor.is_a?(Hash) && successor["event"].is_a?(Hash)
491
+ checks << Check.new(name: name, passed: false,
492
+ detail: "The successor must embed its canonical event body.")
493
+ return
494
+ end
495
+
496
+ event = successor["event"]
497
+ event_id = event["event_id"].to_s
498
+ name = "lifecycle_successor:#{event_id.empty? ? index : event_id}"
499
+ recorded = successor["event_digest"].to_s
500
+ algorithm = Digest.algorithm_of(recorded)
501
+ problems = []
502
+ problems << "event id is missing" if event_id.empty?
503
+ problems << "event id is duplicated" if seen[event_id]
504
+ seen[event_id] = true unless event_id.empty?
505
+ problems << "event digest is missing or unsupported" unless algorithm && Digest.supported?(algorithm)
506
+
507
+ if algorithm && Digest.supported?(algorithm)
508
+ computed = Digest.digest_canonical(event, algorithm: algorithm)
509
+ problems << "event digest does not match" unless Digest.secure_compare?(computed, recorded)
510
+ end
511
+
512
+ projections = {
513
+ "event_id" => [successor["event_id"], event["event_id"]],
514
+ "event_type" => [successor["event_type"], event["event_type"]],
515
+ "recorded_at_by_server" => [successor["recorded_at_by_server"],
516
+ event["recorded_at_by_server"]],
517
+ "reason" => [successor["reason"], event["reason"]]
518
+ }
519
+ mismatches = projections.filter_map { |field, values| field unless values[0] == values[1] }
520
+ problems << "summary differs from embedded event (#{mismatches.join(", ")})" if mismatches.any?
521
+
522
+ root_id = root_body["event_id"].to_s
523
+ linked = event["root_event_id"].to_s == root_id || event["predecessor_event_id"].to_s == root_id
524
+ problems << "event is not linked to receipt #{root_id}" unless linked
525
+
526
+ checks << Check.new(
527
+ name: name,
528
+ passed: problems.empty?,
529
+ detail: if problems.empty?
530
+ "The embedded successor event and its summary match #{recorded}."
531
+ else
532
+ problems.join("; ")
533
+ end
534
+ )
535
+ end
536
+
537
+ def check_integrity_attestations(body, checks)
538
+ attestations = body.dig("integrity", "attestations")
539
+ expected_tier = "baseline"
540
+
541
+ if attestations
542
+ unless attestations.is_a?(Array)
543
+ checks << Check.new(name: "integrity_attestations", passed: false,
544
+ detail: "integrity.attestations must be an array.")
545
+ return
546
+ end
547
+
548
+ verified_kinds = attestations.filter_map.with_index do |attestation, index|
549
+ check = check_integrity_attestation(body, attestation, index)
550
+ checks << check
551
+ attestation["kind"] if check.passed? && attestation["state"] == "verified"
552
+ end
553
+
554
+ timestamp = attestations.find do |attestation|
555
+ verified_kinds.include?("third_party_timestamp") &&
556
+ attestation.is_a?(Hash) && attestation["kind"] == "third_party_timestamp" &&
557
+ attestation.dig("adapter_capabilities", "independently_verifiable") == true
558
+ end
559
+ external_anchor = attestations.find do |attestation|
560
+ verified_kinds.include?("event_anchor") &&
561
+ attestation.is_a?(Hash) && attestation["kind"] == "event_anchor" &&
562
+ attestation.dig("adapter_capabilities", "publishes_outside_primary_database") == true
563
+ end
564
+
565
+ expected_tier = if timestamp
566
+ "third_party_timestamp"
567
+ elsif external_anchor
568
+ "external_event_anchoring"
569
+ elsif body.dig("integrity", "chain_scope")
570
+ "chained_history"
571
+ else
572
+ "baseline"
573
+ end
574
+ elsif body.dig("integrity", "chain_scope")
575
+ expected_tier = "chained_history"
576
+ end
577
+
578
+ actual_tier = body.dig("integrity", "tier").to_s
579
+ checks << Check.new(
580
+ name: "integrity_tier",
581
+ passed: actual_tier == expected_tier,
582
+ detail: if actual_tier == expected_tier
583
+ "The advertised #{actual_tier} tier matches the verified evidence in this receipt."
584
+ else
585
+ "The receipt advertises #{actual_tier.inspect}, but its verifiable evidence supports " \
586
+ "#{expected_tier.inspect}."
587
+ end
588
+ )
589
+ end
590
+
591
+ def check_integrity_attestation(body, attestation, index)
592
+ name = "integrity_attestation:#{index}"
593
+ unless attestation.is_a?(Hash)
594
+ return Check.new(name: name, passed: false,
595
+ detail: "The attestation is not a JSON object.")
596
+ end
597
+
598
+ kind = attestation["kind"].to_s
599
+ state = attestation["state"].to_s
600
+ name = "integrity_attestation:#{kind.empty? ? index : kind}:#{index}"
601
+ recorded = attestation["attestation_digest"].to_s
602
+ algorithm = Digest.algorithm_of(recorded)
603
+ body_without_digest = attestation.except("attestation_digest")
604
+ problems = []
605
+ problems << "attestation kind is not recognized" unless
606
+ %w[event_anchor third_party_timestamp].include?(kind)
607
+ problems << "attestation state is not recognized" unless
608
+ %w[verified issued_unverified unavailable failed].include?(state)
609
+
610
+ problems << "attestation digest is missing or unsupported" unless algorithm && Digest.supported?(algorithm)
611
+ if algorithm && Digest.supported?(algorithm)
612
+ computed = Digest.digest_canonical(body_without_digest, algorithm: algorithm)
613
+ problems << "attestation digest does not match" unless Digest.secure_compare?(computed, recorded)
614
+ end
615
+
616
+ problems << "attestation belongs to a different event" unless
617
+ attestation["event_id"].to_s == body["event_id"].to_s
618
+ problems << "attestation covers a different event digest" unless
619
+ attestation["subject_digest"].to_s == body.dig("integrity", "event_digest").to_s
620
+
621
+ if kind == "event_anchor"
622
+ problems << "anchor chain scope differs from this event" unless
623
+ attestation["chain_scope"].to_s == body.dig("integrity", "chain_scope").to_s
624
+ problems << "anchor chain sequence differs from this event" unless
625
+ attestation["chain_sequence"] == body.dig("integrity", "chain_sequence")
626
+ end
627
+
628
+ if state == "verified"
629
+ verification = attestation["verification"]
630
+ unless verification.is_a?(Hash) && verification["checked"] == true &&
631
+ verification["verified"] == true
632
+ problems << "state is verified but the recorded adapter verification is not"
633
+ end
634
+ end
635
+
636
+ Check.new(
637
+ name: name,
638
+ passed: problems.empty?,
639
+ detail: if problems.empty?
640
+ "The attestation record is digest-bound to this exact event. Its provider claim is " \
641
+ "reported as recorded; cryptographic provider-token verification remains adapter-specific."
642
+ else
643
+ problems.join("; ")
644
+ end
645
+ )
646
+ end
647
+
648
+ # Every duplicated immutable fact must agree with the embedded canonical
649
+ # event. Checking only ids, acts, and documents would let someone edit the
650
+ # projected actor, authority, subject, outcome, provider, presentation,
651
+ # retention, chain, or timestamps and recompute the self-contained receipt
652
+ # digest while the protected event digest continued to pass.
653
+ def event_projection_mismatches(body, event)
654
+ comparisons = {
655
+ "event_id" => [body["event_id"], event["event_id"]],
656
+ "event_type" => [body["event_type"], event["event_type"]],
657
+ "policy.key" => [body.dig("policy", "key"), event.dig("policy", "key")],
658
+ "policy.revision" => [body.dig("policy", "revision"), event.dig("policy", "revision")],
659
+ "policy.retention_class" => [body.dig("policy", "retention_class"), event.dig("retention", "class")],
660
+ "actor.reference" => [body.dig("actor", "reference"), event.dig("actor", "reference")],
661
+ "actor.attribution" => [body.dig("actor", "attribution"), event.dig("actor", "attribution")],
662
+ "actor.snapshot" => [body.dig("actor", "snapshot"), event.dig("actor", "snapshot")],
663
+ "actor.authentication_method" => [body.dig("actor", "authentication_method"),
664
+ event["authentication_method"]],
665
+ "actor.tenant" => [body.dig("actor", "tenant"), event["tenant"]],
666
+ "actor.subject" => [body.dig("actor", "subject"), event["subject"]],
667
+ "actor.acting_for" => [body.dig("actor", "acting_for"), represented_party_projection(event)],
668
+ "acts" => [body["acts"], event["acts"]],
669
+ "documents" => [body["documents"], event["documents"]],
670
+ "presentation" => [fixed_presentation_projection(body["presentation"]),
671
+ presentation_projection(event)],
672
+ "outcome" => [body["outcome"], event["protected_outcome"]],
673
+ "provider" => [fixed_provider_projection(body["provider"]), provider_projection(event)],
674
+ "lifecycle.root_event_id" => [body.dig("lifecycle", "root_event_id"), event["root_event_id"]],
675
+ "lifecycle.predecessor_event_id" => [body.dig("lifecycle", "predecessor_event_id"),
676
+ event["predecessor_event_id"]],
677
+ "retention.core_event_retained_until" => [body.dig("retention", "core_event_retained_until"),
678
+ event.dig("retention", "retain_core_event_until")],
679
+ "retention.retention_rule" => [body.dig("retention", "retention_rule"),
680
+ event.dig("retention", "rule")],
681
+ "integrity.chain" => [receipt_chain_projection(body), event["chain"]],
682
+ "integrity.request_evidence" => [receipt_request_evidence_projection(body),
683
+ event["request_evidence"]],
684
+ "recorded_at_by_server" => [body["recorded_at_by_server"], event["recorded_at_by_server"]],
685
+ "occurred_at" => [body["occurred_at"], event["occurred_at"]],
686
+ "system.gem_version" => [body.dig("system", "gem_version"), event["gem_version"]],
687
+ "system.application_version" => [body.dig("system", "application_version"),
688
+ event["application_version"]],
689
+ "system.template_version" => [body.dig("system", "template_version"), event["template_version"]],
690
+ "system.canonical_schema_version" => [body.dig("system", "canonical_schema_version"), event["schema"]]
691
+ }
692
+
693
+ comparisons.filter_map { |name, pair| name unless pair[0] == pair[1] }
694
+ end
695
+
696
+ def represented_party_projection(event)
697
+ represented = event.dig("actor", "represented_party")
698
+ return nil unless represented
699
+
700
+ authority = event.dig("actor", "authority") || {}
701
+ compact_hash(
702
+ "type" => represented["type"],
703
+ "reference" => represented["reference"],
704
+ "authority_source" => authority["source"],
705
+ "authority_role" => authority["role"],
706
+ "authority_verified_at" => authority["verified_at"],
707
+ "authority_details" => authority["details"]
708
+ )
709
+ end
710
+
711
+ def fixed_presentation_projection(presentation)
712
+ presentation&.except("proves")
713
+ end
714
+
715
+ def presentation_projection(event)
716
+ presentation = event["presentation"]
717
+ return nil unless presentation
718
+
719
+ manifest = presentation["manifest"] || {}
720
+ compact_hash(
721
+ "manifest_digest" => presentation["manifest_digest"],
722
+ "submit_button_text" => manifest["submit_button_text"],
723
+ "combined_sentence" => manifest.dig("combined_control", "sentence"),
724
+ "combined_statements" => manifest.dig("combined_control", "covers"),
725
+ "locale" => manifest["locale"],
726
+ "capture_channel" => event["capture_channel"],
727
+ "offered_at" => manifest["issued_at"]
728
+ )
729
+ end
730
+
731
+ def fixed_provider_projection(provider)
732
+ provider&.except("note")
733
+ end
734
+
735
+ def provider_projection(event)
736
+ provider = event["provider"]
737
+ return nil unless provider
738
+
739
+ compact_hash(
740
+ "name" => provider["name"],
741
+ "event_id" => provider["event_id"],
742
+ "verification" => provider["verification"]
743
+ )
744
+ end
745
+
746
+ def receipt_chain_projection(body)
747
+ value = compact_hash(
748
+ "scope" => body.dig("integrity", "chain_scope"),
749
+ "sequence" => body.dig("integrity", "chain_sequence"),
750
+ "previous_event_digest" => body.dig("integrity", "previous_event_digest")
751
+ )
752
+ value.empty? ? nil : value
753
+ end
754
+
755
+ def receipt_request_evidence_projection(body)
756
+ digests = body.dig("integrity", "request_evidence_category_binding_digests")
757
+ return nil if digests.nil? || digests.empty?
758
+
759
+ compact_hash(
760
+ "category_digests" => digests,
761
+ "algorithm" => body.dig("integrity", "request_evidence_digest_algorithm"),
762
+ "key_id" => body.dig("integrity", "request_evidence_key_id")
763
+ )
764
+ end
765
+
766
+ def compact_hash(hash)
767
+ hash.compact
768
+ end
769
+
770
+ # Each document in the receipt is checked against the bytes supplied for
771
+ # it, and reported `not_supplied` when none were. A bundle you did not
772
+ # bring is not a bundle that passed.
773
+ def check_documents(body, documents, checks)
774
+ entries = Array(body["documents"])
775
+ supplied = normalize_documents(documents)
776
+
777
+ if entries.empty?
778
+ checks << Check.new(name: "documents", passed: true,
779
+ detail: "This receipt cites no document versions to verify.")
780
+ return
781
+ end
782
+
783
+ checked = {}
784
+ entries.each_with_index do |entry, index|
785
+ check_one_document(entry, index, supplied, checks, checked)
786
+ end
787
+ end
788
+
789
+ def check_one_document(entry, index, supplied, checks, checked)
790
+ unless entry.is_a?(Hash)
791
+ checks << Check.new(name: "document:#{index}", passed: false,
792
+ detail: "The document binding is not a JSON object.")
793
+ return
794
+ end
795
+
796
+ key = entry["key"].to_s
797
+ version = entry["version"].to_s
798
+ locale = entry["locale"].to_s
799
+ identity = document_identity(key, version, locale, fallback: index)
800
+ supplied_entry = supplied[document_identity(key, version, locale)] ||
801
+ supplied["#{key}@#{version}"] || supplied[key]
802
+ source_bytes = document_artifact_bytes(supplied, supplied_entry, key, version, locale, "source")
803
+ if source_bytes.nil? && !supplied_entry.is_a?(Hash) &&
804
+ entry["source_digest"].to_s == entry["rendered_digest"].to_s
805
+ source_bytes = supplied_entry
806
+ end
807
+
808
+ check_document_artifact(
809
+ identity: identity,
810
+ artifact: "source",
811
+ expected: entry["source_digest"],
812
+ bytes: source_bytes,
813
+ checks: checks,
814
+ checked: checked
815
+ )
816
+
817
+ check_document_artifact(
818
+ identity: identity,
819
+ artifact: "rendered",
820
+ expected: entry["rendered_digest"],
821
+ bytes: document_artifact_bytes(supplied, supplied_entry, key, version, locale, "rendered"),
822
+ checks: checks,
823
+ checked: checked
824
+ )
825
+ end
826
+
827
+ def check_document_artifact(identity:, artifact:, expected:, bytes:, checks:, checked:)
828
+ name = "document:#{identity}:#{artifact}"
829
+ return if checked[name]
830
+
831
+ checked[name] = true
832
+ expected = expected.to_s
833
+
834
+ if expected.empty? || Digest.algorithm_of(expected).nil?
835
+ checks << Check.new(
836
+ name: name, passed: false,
837
+ detail: "The receipt carries no supported #{artifact}_digest for this document artifact."
838
+ )
839
+ return
840
+ end
841
+
842
+ if bytes.nil?
843
+ checks << Check.new(
844
+ name: name, passed: nil,
845
+ detail: "not_supplied — no #{artifact} bytes were provided, so the recorded " \
846
+ "digest #{expected} was not checked against anything."
847
+ )
848
+ return
849
+ end
850
+
851
+ checks << if Digest.matches?(bytes, expected)
852
+ Check.new(name: name, passed: true,
853
+ detail: "The supplied bytes hash to the recorded digest #{expected}.")
854
+ else
855
+ Check.new(
856
+ name: name, passed: false,
857
+ detail: "The supplied #{artifact} bytes do not hash to #{expected}. These are not " \
858
+ "the #{artifact} bytes this receipt binds."
859
+ )
860
+ end
861
+ end
862
+
863
+ # A caller may be maximally explicit:
864
+ #
865
+ # { "terms@2026-08" => { source: source_bytes, rendered: html_bytes } }
866
+ #
867
+ # Flat `terms@2026-08:source` / `:rendered` keys work too. A bare String
868
+ # means the rendered representation the receipt records as offered. If
869
+ # source and rendered digests are the same, that one byte string can
870
+ # legitimately verify both artifacts.
871
+ def document_artifact_bytes(supplied, entry, key, version, locale, artifact)
872
+ explicit = supplied["#{document_identity(key, version, locale)}:#{artifact}"] ||
873
+ supplied["#{key}@#{version}:#{artifact}"] || supplied["#{key}:#{artifact}"]
874
+ return explicit unless explicit.nil?
875
+
876
+ if entry.is_a?(Hash)
877
+ return entry[artifact] || entry[artifact.to_sym] || entry["#{artifact}_bytes"] ||
878
+ entry[:"#{artifact}_bytes"]
879
+ end
880
+
881
+ return entry if artifact == "rendered"
882
+
883
+ nil
884
+ end
885
+
886
+ def document_identity(key, version, locale, fallback: nil)
887
+ parts = [key, version, locale].reject(&:empty?)
888
+ parts.empty? ? fallback.to_s : parts.join("@")
889
+ end
890
+
891
+ # Chain linkage, when a receipt carries any. A single receipt can only
892
+ # show that its own links are internally coherent — a scope with a
893
+ # sequence, a previous digest that is well-formed. Proving a chain has
894
+ # not been rewritten needs the neighbouring receipts and a head held
895
+ # somewhere the database operator does not control, and this verifier
896
+ # says so rather than implying it checked more than it did.
897
+ def check_chain_linkage(body, checks)
898
+ integrity = body[INTEGRITY_KEY]
899
+ return unless integrity.is_a?(Hash)
900
+
901
+ scope = integrity["chain_scope"]
902
+ sequence = integrity["chain_sequence"]
903
+ previous = integrity["previous_event_digest"]
904
+
905
+ if scope.nil? && sequence.nil? && previous.nil?
906
+ checks << Check.new(
907
+ name: "chain_linkage", passed: true,
908
+ detail: "This receipt is not chained. Chaining is optional and off by default; its " \
909
+ "absence is a configuration fact, not a finding."
910
+ )
911
+ return
912
+ end
913
+
914
+ problems = chain_problems(scope, sequence, previous)
915
+
916
+ checks << if problems.empty?
917
+ Check.new(
918
+ name: "chain_linkage", passed: true,
919
+ detail: "Chain scope #{scope.inspect} at sequence #{sequence} links to #{previous}. " \
920
+ "Internally consistent only: verifying that the chain itself was not " \
921
+ "rewritten needs the neighbouring receipts and an independently held head."
922
+ )
923
+ else
924
+ Check.new(name: "chain_linkage", passed: false, detail: problems.join(" "))
925
+ end
926
+ end
927
+
928
+ def chain_problems(scope, sequence, previous)
929
+ problems = []
930
+ problems << "The receipt has chain fields but no chain_scope." if scope.nil?
931
+
932
+ if sequence.nil?
933
+ problems << "The receipt has a chain_scope but no chain_sequence."
934
+ elsif !sequence.is_a?(Integer) || sequence.negative?
935
+ problems << "chain_sequence #{sequence.inspect} is not a non-negative integer."
936
+ end
937
+
938
+ if previous && Digest.algorithm_of(previous).nil?
939
+ problems << "previous_event_digest #{previous.inspect} is not an algorithm-prefixed digest."
940
+ end
941
+
942
+ # Sequence one is the first event of a chain and legitimately has no
943
+ # predecessor; any later position must name one.
944
+ if previous.nil? && sequence.is_a?(Integer) && sequence > 1
945
+ problems << "Sequence #{sequence} is not the first event of its chain but names no " \
946
+ "previous_event_digest."
947
+ end
948
+
949
+ problems
950
+ end
951
+
952
+ def normalize_documents(documents)
953
+ (documents || {}).to_h { |key, value| [key.to_s, value] }
954
+ end
955
+
956
+ def deep_copy(value)
957
+ case value
958
+ when Hash then value.to_h { |key, nested| [key, deep_copy(nested)] }
959
+ when Array then value.map { |nested| deep_copy(nested) }
960
+ else value
961
+ end
962
+ end
963
+ end
964
+
965
+ class DuplicateJsonKey < StandardError; end
966
+
967
+ # JSON.parse target that refuses duplicate object keys instead of silently
968
+ # keeping the last value and verifying a different object than a reader saw.
969
+ class UniqueKeyHash < Hash
970
+ def []=(key, value)
971
+ raise DuplicateJsonKey, "The JSON object repeats key #{key.inspect}." if key?(key)
972
+
973
+ super
974
+ end
975
+ end
976
+ private_constant :DuplicateJsonKey, :UniqueKeyHash
977
+ end
978
+ end