Module: Clickwrap::CurrentState

Defined in:
lib/clickwrap/current_state.rb

Overview

Projects retained event payloads onto the current-state table.

Everything here is derived from retained event payloads. rebuild_for! replays them while they still contain the affected statement identity. A reviewed core disposition deliberately removes that identity, so rebuilding through a disposed root would invent or silently lose state. The method refuses first and leaves the existing projection untouched in that case.

Constant Summary collapse

INITIAL_EVENT_TYPES =

imported_legacy projects like a capture: the source system says the act happened, and a migrated application must keep answering "did this person agree?" the way it answered the day before the migration — otherwise every import ends in a mass forced re-acceptance, which is exactly the history-rewriting an import exists to avoid. What stays different is the evidence, not the answer: the event keeps imported_provider attribution, its receipt names every unknown, and require_current_version still sends people back when the documents move on. (An exemption, by contrast, records that NO human acted — it projects under its own state and never satisfies a human-action predicate.)

(Vocabulary::HUMAN_ACTION_EVENT_TYPES + %w[exemption imported_legacy]).freeze
TRANSITION_STATE_BY_EVENT_TYPE =
{
  "withdrawal" => "withdrawn",
  "supersession" => "superseded",
  "expiry" => "expired",
  "consumption" => "consumed",
  "revocation" => "revoked"
}.freeze

Class Method Summary collapse

Class Method Details

.apply!(event) ⇒ Object

Applies one event to the projection. Runs inside the capture's transaction, so a failure here rolls the capture back rather than leaving evidence whose current state nobody can query.



36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
# File 'lib/clickwrap/current_state.rb', line 36

def apply!(event)
  return event unless INITIAL_EVENT_TYPES.include?(event.event_type)
  # An import quarantined with `counts_as_current: false` must stay
  # quarantined through every path — including a projection REBUILD.
  # Without this guard, `rebuild_for!` would launder consent the host
  # explicitly declined to honour into an active grant.
  return event unless projects_into_current_state?(event)

  StatementIdentityLock.acquire_for_actor!(event.actor_reference)
  identities = event.statements.map { |statement| identity_for(event, statement) }
  identities.sort_by { |identity| identity.fetch(:identity_digest) }.each do |identity|
    StatementIdentityLock.acquire!(identity.fetch(:identity_digest))
  end

  event.statements.each { |statement| apply_statement!(event, statement) }
end

.apply_statement!(event, statement) ⇒ Object



53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
# File 'lib/clickwrap/current_state.rb', line 53

def apply_statement!(event, statement)
  identity = identity_for(event, statement)

  loop do
    return StatementState.transaction(requires_new: true) do
      state = StatementState.find_or_initialize_by(identity)
      return state if candidate_is_not_newer?(state, event, statement)

      # A previous grant for the same identity is superseded rather
      # than overwritten. The projection moves on; the event that
      # recorded the earlier act stays exactly where it was.
      state.assign_attributes(
        kind: statement.kind,
        purpose_key: statement.purpose_key,
        actor_type: event.actor_type,
        actor_id: event.actor_id,
        subject_type: event.subject_type,
        subject_id: event.subject_id,
        subject_fingerprint: statement.subject_fingerprint,
        represented_party_reference: event.represented_party_reference.to_s,
        state: state_for(event, statement),
        current_action: statement.action,
        current_event_id: event.id,
        root_event_id: event.root_event_id || event.id,
        policy_revision_id: event.policy_revision_id,
        effective_at: effective_at_for(event, statement),
        expires_at: statement.expires_at,
        one_time: statement.one_time,
        document_version_ids: document_version_ids_for(event, statement)
      )

      apply_lifecycle_timestamps(state, event, statement)
      state.save!
      state
    end
  rescue ::ActiveRecord::RecordNotUnique
    # PostgreSQL marks a transaction failed after a uniqueness error.
    # The requires_new savepoint above absorbs that state before retry.
  end
end

.projects_into_current_state?(event) ⇒ Boolean

Whether this event is allowed to shape the projection. Captures and every other initial type always do; an imported_legacy event carries its own answer in the structured provenance it was written with. Imports that predate the flag project (they were written with exactly that intent); only an explicit counts_as_current: false quarantines.

Returns:

  • (Boolean)


145
146
147
148
149
# File 'lib/clickwrap/current_state.rb', line 145

def projects_into_current_state?(event)
  return true unless event.event_type == "imported_legacy"

  event.provider_verification.to_h["counts_as_current"] != false
end

.rebuild_for!(actor_reference:) ⇒ Object

Rebuilds the projection for one actor from retained event payloads. Refuses before deleting anything when an existing state depends on a disposed root whose statement identity can no longer be reconstructed.



125
126
127
128
129
130
131
132
133
134
135
136
137
138
# File 'lib/clickwrap/current_state.rb', line 125

def rebuild_for!(actor_reference:)
  StatementState.transaction do
    StatementIdentityLock.acquire_for_actor!(actor_reference)
    existing_states = StatementState.for_actor(actor_reference).lock.to_a
    ensure_rebuildable!(existing_states, actor_reference)
    StatementState.for_actor(actor_reference).delete_all

    Event.for_actor(actor_reference)
         .chronological
         .includes(:statements)
         .to_a
         .each { |event| replay!(event) }
  end
end

.transition!(state, to:, event:, at: nil) ⇒ Object

Marks a statement's projection with a lifecycle outcome, without touching the event that produced it.



96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
# File 'lib/clickwrap/current_state.rb', line 96

def transition!(state, to:, event:, at: nil)
  at ||= Clickwrap.now

  StatementIdentityLock.acquire_for_actor!(state.actor_reference)
  StatementIdentityLock.acquire!(state.identity_digest)
  state = StatementState.lock.find(state.id)
  return state if event_is_not_newer?(state, event, effective_at: at)

  attributes = {
    state: to.to_s,
    current_event_id: event.id,
    current_action: event_action_for(to),
    effective_at: at
  }

  case to.to_s
  when "withdrawn" then attributes[:withdrawn_at] = at
  when "superseded" then attributes[:superseded_at] = at
  when "consumed" then attributes[:consumed_at] = at
  when "revoked" then attributes[:revoked_at] = at
  when "corrected" then attributes[:corrected_at] = at
  end

  state.update!(attributes)
end