clickwrap 0.3.2 → 0.3.4

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1ea29dba21d2740876c9708ad687520310951911eab737844b59577fbbe24464
4
- data.tar.gz: 01140025f14f2c137ba35a25ab694898d31fdbc719a42a488d63204007b0dc85
3
+ metadata.gz: 6a3a937701ee11dbf64dc080a8299fb1130210c6776b8693f79e8ed8d42a3fd8
4
+ data.tar.gz: 52aec9d2d61684846076dbf8250530c73246da207533cfcc032855004b5a8680
5
5
  SHA512:
6
- metadata.gz: 752ed3dda0897564843222843c444a9c5c945493152ec04e697241871afda8df0eeea9635b3f07296a392f58c46abe763366daa759f0916cd5bb455f56a04e01
7
- data.tar.gz: 71e5c6512a165734f941b7e5483b399e8d46e83a3963e05af5ae2d3e077dbcf4eac0b0e7eddebc9ff61b7a9d3b73c551502fb434388c65c1830e2b75a6e0add1
6
+ metadata.gz: 170382234eebd4e10dea7d3248d8e829d0db40f764441ad264335d0b73dcb5806b71cf03078a1ed582053ff3e5b971d7f8b8309e56df0845f77f38c63346cc52
7
+ data.tar.gz: 7be06ef7af2f171fbcde39759f19dd81096307e095e60944df357c2aba58f8614b17c1335e8efa26a78fbb459aafb3a36baef4b479ac0513fdb66fc55420d6c0
data/CHANGELOG.md CHANGED
@@ -6,6 +6,78 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.3.4] - 2026-10-24
10
+
11
+ ### Added — ask whether the Terms in force were agreed, under any policy
12
+
13
+ - **`user.clickwraps.agreed_to_current_version_of?(:terms)`.** An invoicing
14
+ app on the fleet records the Terms agreement under two policies, signup and
15
+ re-acceptance, and had to write this query itself, because `current_for?`
16
+ reads one policy and `agreed_to?` ignores versions. The predicate is true
17
+ when a satisfied agreement to that statement, under any policy, recorded
18
+ the version in force now of each document the statement binds. A version
19
+ with a later `effective_at` counts only from that time. Until then, the
20
+ agreement to the version in force still counts. No agreement is false, and
21
+ so is an agreement under a policy that is no longer declared. It takes the
22
+ `subject:`, `tenant:` and `acting_for:` keywords of `agreed_to?`, with the
23
+ same defaults.
24
+
25
+ ```ruby
26
+ requires_clickwrap :current_terms,
27
+ unless: -> { current_user.clickwraps.agreed_to_current_version_of?(:terms) }
28
+ ```
29
+
30
+ ### Fixed — tenant evidence re-asks when the shared document moves on
31
+
32
+ - **`require_current_version: true` never re-asked evidence recorded in a
33
+ tenant that was offered the shared document.** The presenter offers a
34
+ tenant its own document and falls back to the shared one. The staleness
35
+ check looked only for the tenant's own document, found none, and answered
36
+ "current" whatever version was in force. The check now resolves the
37
+ document the way the presenter does, so a new shared version re-asks that
38
+ evidence. Evidence against a tenant's own document is unchanged.
39
+
40
+ ### Changed
41
+
42
+ - The "is this version current?" rule moved from a private method of
43
+ `Clickwrap::Verification` to `StatementState#document_key_with_unseen_current_version`.
44
+ `require_current_version: true` and the new predicate call the same method,
45
+ so they cannot disagree.
46
+ - The rule reads version ids only, through `Clickwrap::DocumentVersionsInForce`,
47
+ and never loads document bytes. The predicate looks up each document once
48
+ per call, however many policies recorded the agreement.
49
+ `Document#current_version` and this lookup share one scope,
50
+ `DocumentVersion.presentable_newest_first`.
51
+
52
+ ## [0.3.3] - 2026-08-31
53
+
54
+ ### Fixed — the actor class survives a reload
55
+
56
+ - **`config.actor_class` followed the constant it had cached, not the one the
57
+ app has.** In a Rails app with reloading, every capture after the first code
58
+ change of a session raised
59
+
60
+ ```
61
+ Clickwrap was asked to record User as the actor, but
62
+ `config.actor_class_name` says the records that can act are User.
63
+ ```
64
+
65
+ Both halves name the same class, which reads as a contradiction and sends
66
+ whoever hits it to an initializer that is correct. They were two different
67
+ Ruby objects printing the same name: `actor_class` memoized the
68
+ constantized `Class` and re-resolved only when the class NAME changed —
69
+ which never happens, since the name is set once in an initializer — while
70
+ Zeitwerk replaces the class behind that name on every edit. The cache then
71
+ held a class no living record was an instance of, so
72
+ `actor.is_a?(config.actor_class)` was false for an ordinary `User` and
73
+ capture refused to record it.
74
+
75
+ The memo is gone. `constantize` after the first load is a `const_get` and
76
+ this is called once per capture rather than in a loop, so it was never
77
+ buying anything; `parent_controller_class` has always resolved this way.
78
+ Production was never affected — classes are not reloaded there — but every
79
+ development session was, from its second edit onwards.
80
+
9
81
  ## [0.3.2] - 2026-08-20
10
82
 
11
83
  ### Fixed — two guards that looked like they held
data/README.md CHANGED
@@ -569,6 +569,7 @@ The actor proxy is the everyday API:
569
569
  ```ruby
570
570
  user.clickwraps.current_for?(:signup)
571
571
  user.clickwraps.agreed_to?(:terms)
572
+ user.clickwraps.agreed_to_current_version_of?(:terms)
572
573
  user.clickwraps.consented_to?(:product_updates)
573
574
  user.clickwraps.declared?(:independent_contractor, subject: scheme)
574
575
  user.clickwraps.authorized?(:withdrawal, subject: withdrawal)
@@ -623,6 +624,17 @@ Publish a new version and `current_for?` flips to `false` for everyone who accep
623
624
  bin/rails clickwrap:reacceptance:plan POLICY=current_terms
624
625
  ```
625
626
 
627
+ `current_for?(:current_terms)` reads only the evidence recorded under `:current_terms`. A person who signed up an hour ago agreed to the Terms in force under `:signup`, and has nothing under `:current_terms` yet. A gate on that policy alone sends them back to accept the same text again. Ask across every policy that records the agreement instead:
628
+
629
+ ```ruby
630
+ class DashboardController < ApplicationController
631
+ requires_clickwrap :current_terms,
632
+ unless: -> { current_user.clickwraps.agreed_to_current_version_of?(:terms) }
633
+ end
634
+ ```
635
+
636
+ `agreed_to_current_version_of?` applies the rule `require_current_version: true` applies. A version published with a later `effective_at` counts only from that time, so the gate starts on the effective date and not on the deploy that gives notice. Until then, the agreement to the version in force still counts. It takes the `subject:`, `tenant:` and `acting_for:` keywords of `agreed_to?`, with the same defaults. Like `agreed_to?`, it reads the current-state projection; `Clickwrap.verify` is the call that rechecks the evidence itself.
637
+
626
638
  ## Consent that can actually be withdrawn
627
639
 
628
640
  Consent is purpose-specific, initially unselected, and separate from Terms:
@@ -1015,6 +1027,8 @@ class ApplicationController < ActionController::Base
1015
1027
  end
1016
1028
  ```
1017
1029
 
1030
+ A person who accepted the Terms in force at signup did so under `:signup`, not under `:current_terms`. Let them through as [Reacceptance when documents change](#reacceptance-when-documents-change) shows.
1031
+
1018
1032
  Publish the new version and every signed-in user gets redirected to the acceptance screen on their next request — and sent back to wherever they were going the moment they accept. Preview the blast radius before you activate it:
1019
1033
 
1020
1034
  ```bash
@@ -256,6 +256,7 @@ way past — it is the shape of the code.
256
256
  user.clickwraps.current_for?(:signup)
257
257
  user.clickwraps.required_for?(:current_terms)
258
258
  user.clickwraps.agreed_to?(:terms)
259
+ user.clickwraps.agreed_to_current_version_of?(:terms) # under any policy
259
260
  user.clickwraps.acknowledged?(:privacy_notice)
260
261
  user.clickwraps.consented_to?(:product_updates)
261
262
  user.clickwraps.declared?(:independent_contractor, subject: scheme)
@@ -34,6 +34,37 @@ module Clickwrap
34
34
  satisfied?("agreement", statement_key, subject: subject, tenant: tenant, acting_for: acting_for)
35
35
  end
36
36
 
37
+ # Whether the actor agreed to the version in force now of each document
38
+ # this statement binds, under ANY policy that records the statement.
39
+ #
40
+ # Neither neighbour answers this. `agreed_to?` reads the newest agreement
41
+ # and ignores versions. `current_for?` checks versions inside one policy
42
+ # only. A signup form and a re-acceptance screen usually record the same
43
+ # `agree_to :terms` under two policies, so a person who signed up an hour
44
+ # ago has the version in force under `:signup` and nothing under
45
+ # `:current_terms`. A gate on `current_for?(:current_terms)` alone sends
46
+ # that person back to accept the same text again.
47
+ #
48
+ # "In force" is the rule `require_current_version: true` applies: a version
49
+ # with a later `effective_at` counts only from that time, and until then
50
+ # the acceptance of the version it replaces still counts. An agreement that
51
+ # no longer stands does not count. No agreement at all is false.
52
+ #
53
+ # The scope keywords mean what they mean on `agreed_to?`, and so do their
54
+ # defaults: no subject, no tenant, not acting for anyone. That default is
55
+ # where a `tenant_is :not_applicable` policy records its evidence.
56
+ def agreed_to_current_version_of?(statement_key, subject: nil, tenant: nil, acting_for: nil)
57
+ # Each policy that recorded the agreement usually binds the same
58
+ # documents, so their versions in force are looked up once per call.
59
+ versions_in_force = DocumentVersionsInForce.new
60
+
61
+ states(subject: subject, tenant: tenant, acting_for: acting_for)
62
+ .for_statement(statement_key)
63
+ .where(kind: "agreement")
64
+ .order(effective_at: :desc)
65
+ .any? { |state| state.satisfies? && agreed_to_current_versions?(state, versions_in_force) }
66
+ end
67
+
37
68
  def acknowledged?(statement_key, subject: nil, tenant: nil, acting_for: nil)
38
69
  satisfied?("acknowledgment", statement_key, subject: subject, tenant: tenant, acting_for: acting_for)
39
70
  end
@@ -105,6 +136,18 @@ module Clickwrap
105
136
  )
106
137
  end
107
138
 
139
+ # The documents come from the statement as its policy declares it now,
140
+ # exactly as `require_current_version` reads them. Evidence under a policy
141
+ # that is no longer declared has nothing to check against, and "cannot
142
+ # check" is never "yes".
143
+ def agreed_to_current_versions?(state, versions_in_force)
144
+ statement = Clickwrap.policies[state.policy_key]&.statement(state.statement_key)
145
+ return false unless statement
146
+
147
+ state.document_key_with_unseen_current_version(statement.document_keys,
148
+ versions_in_force: versions_in_force).nil?
149
+ end
150
+
108
151
  def satisfied?(kind, statement_key, subject:, tenant:, acting_for:)
109
152
  state = state_for(kind, statement_key, subject: subject, tenant: tenant, acting_for: acting_for)
110
153
  !state.nil? && state.satisfies?
@@ -307,16 +307,29 @@ module Clickwrap
307
307
  @describe_authentication_with = ensure_callable(value, "describe_authentication_with")
308
308
  end
309
309
 
310
- # The constantized actor class, resolved lazily on first use. Lazy on
311
- # purpose: the initializer that sets `config.actor_class_name = "User"` runs
312
- # before the User model is necessarily loaded.
310
+ # The constantized actor class, resolved lazily on EVERY use. Lazy on
311
+ # purpose: the initializer that sets `config.actor_class_name = "User"`
312
+ # runs before the User model is necessarily loaded.
313
+ #
314
+ # Not memoized, deliberately. This used to cache the resolved Class object
315
+ # and re-resolve only when the class NAME changed — which is never, since
316
+ # the name is set once in an initializer. In a Rails app with reloading,
317
+ # the class behind that name is replaced on every code change: Zeitwerk
318
+ # unloads the old User and defines a new one, same name, different object.
319
+ # The cache then held a class no living record was an instance of, and
320
+ # `actor.is_a?(config.actor_class)` failed for a perfectly ordinary User.
321
+ #
322
+ # What that looked like from the outside was worse than the bug: the
323
+ # ConfigurationError names the offending class and the configured class,
324
+ # and both printed "User". The message read as a contradiction, sent
325
+ # people to an initializer that was correct, and came back after every
326
+ # edit — the first signup of a dev session worked and the second did not.
327
+ #
328
+ # `constantize` after the first load is a const_get, and this is called
329
+ # once per capture rather than in a loop, so there was nothing to save.
330
+ # `parent_controller_class` below has always resolved this way.
313
331
  def actor_class
314
- name = actor_class_name
315
- if @actor_class.nil? || @actor_class_name_at_resolution != name
316
- @actor_class = name.constantize
317
- @actor_class_name_at_resolution = name
318
- end
319
- @actor_class
332
+ actor_class_name.constantize
320
333
  end
321
334
 
322
335
  def parent_controller_class = parent_controller_class_name.constantize
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clickwrap
4
+ # The id of the version in force of each document: the version
5
+ # `Document#current_version` returns, for one locale at one moment.
6
+ #
7
+ # A re-acceptance gate asks this on every protected write, once for each
8
+ # policy that recorded the agreement. Those policies usually bind the same
9
+ # documents, so each document is looked up once and the answer is kept for
10
+ # the life of this object. The lookup reads ids only, because a version row
11
+ # also holds the document's bytes and this question needs none of them.
12
+ #
13
+ # A tenant's own document wins over the shared one, as the presenter chooses
14
+ # them. Evidence recorded in a tenant that was offered the shared document
15
+ # is therefore compared with the shared document's version in force.
16
+ class DocumentVersionsInForce
17
+ def initialize(locale: I18n.locale, at: Clickwrap.now)
18
+ @locale = locale
19
+ @at = at
20
+ @ids = {}
21
+ end
22
+
23
+ # nil when the document is not published or has no version in force.
24
+ def id_for(document_key, tenant_key: nil)
25
+ key = [document_key.to_s, tenant_key.presence]
26
+ return @ids[key] if @ids.key?(key)
27
+
28
+ @ids[key] = look_up(*key)
29
+ end
30
+
31
+ private
32
+
33
+ def look_up(document_key, tenant_key)
34
+ document_id = document_id_for(document_key, tenant_key)
35
+ return nil unless document_id
36
+
37
+ DocumentVersion.where(document_id: document_id).presentable_newest_first(@at, @locale).pick(:id)
38
+ end
39
+
40
+ def document_id_for(document_key, tenant_key)
41
+ ids = Document.where(document_key: document_key, tenant_key: [tenant_key, nil].uniq)
42
+ .pluck(:tenant_key, :id)
43
+ .to_h
44
+
45
+ ids.fetch(tenant_key) { ids[nil] }
46
+ end
47
+ end
48
+ end
@@ -28,13 +28,7 @@ module Clickwrap
28
28
  # always writes an `effective_at`, so no NULL reaches this comparison and
29
29
  # PostgreSQL and SQLite agree about which document the server offered.
30
30
  def current_version(locale: I18n.locale, at: Clickwrap.now)
31
- versions
32
- .published
33
- .effective_at_or_before(at)
34
- .not_retired_at(at)
35
- .for_locale(locale)
36
- .order(effective_at: :desc, published_at: :desc, created_at: :desc)
37
- .first
31
+ versions.presentable_newest_first(at, locale).first
38
32
  end
39
33
 
40
34
  def version(label, locale: I18n.locale)
@@ -34,6 +34,17 @@ module Clickwrap
34
34
  scope :effective_at_or_before, ->(moment) { where(effective_at: ..moment).or(where(effective_at: nil)) }
35
35
  scope :not_retired_at, ->(moment) { where(retired_at: nil).or(where(retired_at: moment...)) }
36
36
 
37
+ # The versions a policy may present at `moment` in `locale`, the one to
38
+ # present first. `Document#current_version` and
39
+ # `DocumentVersionsInForce` both read this scope, so they cannot disagree.
40
+ scope :presentable_newest_first, lambda { |moment, locale|
41
+ published
42
+ .effective_at_or_before(moment)
43
+ .not_retired_at(moment)
44
+ .for_locale(locale)
45
+ .order(effective_at: :desc, published_at: :desc, created_at: :desc)
46
+ }
47
+
37
48
  # Publishing freezes content. Editing a published version is refused here
38
49
  # rather than in a code review, because the whole promise of this table is
39
50
  # that its rows do not change.
@@ -86,6 +86,26 @@ module Clickwrap
86
86
  true
87
87
  end
88
88
 
89
+ # The first of these documents whose version in force now is not one this
90
+ # state recorded, or nil when this state recorded each of them. "In force"
91
+ # is `Document#current_version` for the request's locale, with this
92
+ # state's tenant document first and the shared document after it, as the
93
+ # presenter offered them. So a version published with a later
94
+ # `effective_at` changes nothing before that time, and a document with no
95
+ # version in force changes nothing either. `require_current_version: true`
96
+ # and `agreed_to_current_version_of?` both ask this method, so a gate and
97
+ # a predicate never disagree about which version is current. Pass one
98
+ # `versions_in_force` to several states to look each document up once.
99
+ def document_key_with_unseen_current_version(document_keys, versions_in_force: DocumentVersionsInForce.new)
100
+ recorded = Array(document_version_ids).map(&:to_s)
101
+
102
+ Array(document_keys).find do |document_key|
103
+ current_id = versions_in_force.id_for(document_key, tenant_key: tenant_key)
104
+
105
+ current_id && !recorded.include?(current_id.to_s)
106
+ end
107
+ end
108
+
89
109
  # Why it does not, as one of the stable error symbols applications branch
90
110
  # on. Never an English string: an authorization decision should not depend
91
111
  # on parsing a message.
@@ -218,7 +218,7 @@ module Clickwrap
218
218
  end
219
219
 
220
220
  if statement.requires_current_version?
221
- stale = stale_document?(statement, state)
221
+ stale = state.document_key_with_unseen_current_version(statement.document_keys)
222
222
 
223
223
  if stale
224
224
  return Result.failure(:unseen_document_version,
@@ -482,23 +482,6 @@ module Clickwrap
482
482
  value = statement.subject_fingerprint_with.call(subject)
483
483
  value.nil? ? nil : Digest.digest(value.to_s)
484
484
  end
485
-
486
- # Returns the document key whose current published version is newer than
487
- # the one this evidence was captured against, or nil when everything the
488
- # statement requires is still current.
489
- def stale_document?(statement, state)
490
- recorded = Array(state.document_version_ids).map(&:to_s)
491
-
492
- statement.document_keys.find do |document_key|
493
- document = ::Clickwrap::Document.find_by(
494
- document_key: document_key,
495
- tenant_key: state.tenant_key.presence
496
- )
497
- current = document&.current_version(locale: I18n.locale)
498
-
499
- current && !recorded.include?(current.id.to_s)
500
- end
501
- end
502
485
  end
503
486
  end
504
487
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clickwrap
4
- VERSION = "0.3.2"
4
+ VERSION = "0.3.4"
5
5
 
6
6
  # The canonical schema version for receipts, event digests, and presentation
7
7
  # manifests. This is deliberately independent of VERSION: gem releases may
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: clickwrap
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.2
4
+ version: 0.3.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - rameerez
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-08-20 00:00:00.000000000 Z
10
+ date: 2026-10-24 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: actionpack
@@ -217,6 +217,7 @@ files:
217
217
  - lib/clickwrap/document_renderer.rb
218
218
  - lib/clickwrap/document_renderers/markdown.rb
219
219
  - lib/clickwrap/document_renderers/markdown_rails.rb
220
+ - lib/clickwrap/document_versions_in_force.rb
220
221
  - lib/clickwrap/dsl/policy_builder.rb
221
222
  - lib/clickwrap/dsl/retention_builder.rb
222
223
  - lib/clickwrap/durable_commit_callback.rb