Class: Clickwrap::Retention::Planner

Inherits:
Object
  • Object
show all
Defined in:
lib/clickwrap/retention/planner.rb

Overview

Builds a reviewable disposition plan. Deletes nothing.

plan = Clickwrap::Retention::Planner.new(created_by: current_operator).call
plan.summary   # => counts by policy, by part, plus held and unresolved
plan.id        # => the id `clickwrap:retention:apply PLAN=...` needs

Disposition is always two steps here: plan, review, then apply. This is the first step, and it is deliberately read-only — an operator can run it on a Friday afternoon without consequence, and the numbers it reports are the numbers the applier will re-derive.

Three properties of the report matter more than the totals:

* Held records are counted SEPARATELY rather than silently dropped, so
an operator who expected 4,000 items and sees 12 can tell that a legal
hold is working rather than that the query is broken.

* A host-event rule that resolves to nil is UNRESOLVED, never due. "Five
years, or three years after this contract is liquidated, whichever is
later" has not started its clock until liquidation happens, and a job
that treats "we cannot say yet" as "delete it now" destroys regulated
evidence early. That failure mode is specific, foreseeable, and the
reason this class reports a third category instead of two.

* Nothing here decides a retention period. The host and its counsel
chose the rules; this reads them back and says what they currently
imply.

Defined Under Namespace

Classes: Eligibility, HoldIndex, Item

Constant Summary collapse

KIND =
"retention"
PARTS =

Parts of the evidence a plan can cover. The first four are the parts of an event, named exactly as the retention DSL names them; the fifth is a persisted presentation, which is not evidence of an act and ages out on its own schedule.

(RetentionClass::PARTS + %i[presentation]).freeze
ANNEX_PARTS =
%i[ip_address browser_user_agent ip_geolocation].freeze
EXAMPLE_LIMIT =

How many held and unresolved examples travel in the summary. The counts are always complete; the examples exist so an operator can see what kind of thing is being held without opening a console, and are capped so a plan row stays a plan row rather than a data export.

25

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(at: Clickwrap.now, policy_key: nil, actor_reference: nil, created_by: nil, because: nil) ⇒ Planner

Returns a new instance of Planner.



186
187
188
189
190
191
192
193
# File 'lib/clickwrap/retention/planner.rb', line 186

def initialize(at: Clickwrap.now, policy_key: nil, actor_reference: nil, created_by: nil, because: nil)
  @at = at
  @policy_key = policy_key&.to_s
  @actor_reference = reference_for(actor_reference)
  @created_by = created_by
  @because = because
  @items = []
end

Instance Attribute Details

#actor_referenceObject (readonly)

Returns the value of attribute actor_reference.



195
196
197
# File 'lib/clickwrap/retention/planner.rb', line 195

def actor_reference
  @actor_reference
end

#atObject (readonly)

Returns the value of attribute at.



195
196
197
# File 'lib/clickwrap/retention/planner.rb', line 195

def at
  @at
end

#becauseObject (readonly)

Returns the value of attribute because.



195
196
197
# File 'lib/clickwrap/retention/planner.rb', line 195

def because
  @because
end

#created_byObject (readonly)

Returns the value of attribute created_by.



195
196
197
# File 'lib/clickwrap/retention/planner.rb', line 195

def created_by
  @created_by
end

#itemsObject (readonly)

Returns the value of attribute items.



195
196
197
# File 'lib/clickwrap/retention/planner.rb', line 195

def items
  @items
end

#policy_keyObject (readonly)

Returns the value of attribute policy_key.



195
196
197
# File 'lib/clickwrap/retention/planner.rb', line 195

def policy_key
  @policy_key
end

Class Method Details

.annex_eligibility(annex, part) ⇒ Object

What one annex field's rule currently says. The row carries its own schedule or its own named rule, recorded when the value was captured, so this reads the row rather than today's policy: the person whose IP address it is was told the period that applied then.



144
145
146
147
148
149
150
151
152
153
154
155
# File 'lib/clickwrap/retention/planner.rb', line 144

def annex_eligibility(annex, part)
  delete_after = annex.public_send(:"#{part}_delete_after")
  return Eligibility.new(eligible_at: delete_after, rule: "recorded_schedule") if delete_after.present?

  rule_name = annex.public_send(:"#{part}_retain_until_rule")
  return resolve_host_event(rule_name, annex.event) if rule_name.present?

  Eligibility.new(
    rule: nil,
    unresolved_reason: "No deletion rule is recorded for #{part} on this row."
  )
end

.core_event_eligibility(event) ⇒ Object

What the core event's rule currently says. Shared with the applier so planning and applying can never disagree about what "due" means.

The order matters. A schedule computed at capture (retain_core_event_until) wins, because that is what the receipt already told the world. Only when there is none does this fall back to the rule the class names today. That fallback exists for records written by an older Clickwrap version; current writers freeze a schedule onto captures, imports, exemptions, and lifecycle events when each event is written.



116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
# File 'lib/clickwrap/retention/planner.rb', line 116

def core_event_eligibility(event)
  return Eligibility.new(eligible_at: event.retain_core_event_until, rule: "recorded_schedule") if
    event.retain_core_event_until.present?

  return resolve_host_event(event.retention_rule_name, event) if event.retention_rule_name.present?

  retention_class = Clickwrap.retention_classes[event.retention_class_key.to_s]

  if retention_class.nil?
    return Eligibility.new(
      rule: "retention_class:#{event.retention_class_key}",
      unresolved_reason: "Retention class #{event.retention_class_key.inspect} is no longer " \
                         "defined, so nothing can say when this event is due."
    )
  end

  rule = retention_class.rule_for(:core_event)
  return Eligibility.new(rule: nil, unresolved_reason: "No core-event rule is defined.") if rule.nil?
  return resolve_host_event(rule.host_event_name, event) if rule.host_event?

  Eligibility.new(eligible_at: event.recorded_at_by_server + rule.duration,
                  rule: "duration:#{rule.duration.to_i}s")
end

.resolve_host_event(name, event) ⇒ Object

A host calculation may legitimately return nil ("the triggering event has not happened"), may be unregistered, and may raise — a retention calculation runs the host's own domain code. All three are reported as unresolved with the reason attached, because a disposition run that crashes on one row, or that guesses a date to keep going, is worse than one that says which rows it could not evaluate.



163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
# File 'lib/clickwrap/retention/planner.rb', line 163

def resolve_host_event(name, event)
  return Eligibility.new(rule: nil, unresolved_reason: "No rule name recorded.") if name.blank?

  rule = "host_event:#{name}"
  resolved = Clickwrap.configuration.resolve_retention_time(name, event)

  if resolved.nil?
    return Eligibility.new(
      rule: rule,
      unresolved_reason: "The host calculation #{name} has not resolved yet, so the " \
                         "triggering event has not happened and the clock has not started."
    )
  end

  Eligibility.new(eligible_at: resolved, rule: rule)
rescue StandardError => error
  Eligibility.new(
    rule: "host_event:#{name}",
    unresolved_reason: "The host calculation #{name} could not be evaluated (#{error.class})."
  )
end

Instance Method Details

#callObject

Builds and persists the plan. Nothing is deleted, nothing is marked, and no state changes anywhere else.



199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
# File 'lib/clickwrap/retention/planner.rb', line 199

def call
  SchemaRequirements.require!(:retention_ops)

  @items = []

  plan_core_events
  plan_request_evidence
  plan_presentations

  DispositionPlan.create!(
    kind: KIND,
    disposition_scope: scope_document,
    summary: summary_document,
    item_count: due_items.length,
    created_by_reference: reference_for(created_by),
    reason: because
  )
end

#due_itemsObject



218
# File 'lib/clickwrap/retention/planner.rb', line 218

def due_items = items.select { |item| item.status == :due }

#held_itemsObject



219
# File 'lib/clickwrap/retention/planner.rb', line 219

def held_items = items.select { |item| item.status == :held }

#unresolved_itemsObject



220
# File 'lib/clickwrap/retention/planner.rb', line 220

def unresolved_items = items.select { |item| item.status == :unresolved }