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.



195
196
197
198
199
200
201
202
# File 'lib/clickwrap/retention/planner.rb', line 195

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.



204
205
206
# File 'lib/clickwrap/retention/planner.rb', line 204

def actor_reference
  @actor_reference
end

#atObject (readonly)

Returns the value of attribute at.



204
205
206
# File 'lib/clickwrap/retention/planner.rb', line 204

def at
  @at
end

#becauseObject (readonly)

Returns the value of attribute because.



204
205
206
# File 'lib/clickwrap/retention/planner.rb', line 204

def because
  @because
end

#created_byObject (readonly)

Returns the value of attribute created_by.



204
205
206
# File 'lib/clickwrap/retention/planner.rb', line 204

def created_by
  @created_by
end

#itemsObject (readonly)

Returns the value of attribute items.



204
205
206
# File 'lib/clickwrap/retention/planner.rb', line 204

def items
  @items
end

#policy_keyObject (readonly)

Returns the value of attribute policy_key.



204
205
206
# File 'lib/clickwrap/retention/planner.rb', line 204

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.



153
154
155
156
157
158
159
160
161
162
163
164
# File 'lib/clickwrap/retention/planner.rb', line 153

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
139
140
141
142
143
144
145
146
147
# 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?
  if rule.indefinite?
    # Never due, by design — the reason names the decision, not a gap.
    # (Scopes skip indefinite classes, so this branch is the answer for
    # anyone asking about one event directly, and a guard for the
    # applier's re-check.)
    return Eligibility.new(rule: "indefinite",
                           unresolved_reason: "This event's retention class keeps the core " \
                                              "event indefinitely; it is never due.")
  end
  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.



172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
# File 'lib/clickwrap/retention/planner.rb', line 172

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.



208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
# File 'lib/clickwrap/retention/planner.rb', line 208

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



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

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

#held_itemsObject



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

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

#unresolved_itemsObject



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

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