clickwrap 0.1.0 → 0.2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 98648fe84ac49eb6e6f0aa31206529051b15a13317c28ea8405d1b78f49b6668
4
- data.tar.gz: f3dd9f393dcd3b0ced96de1fa320608e631962c8531edd2365110d7825c88d85
3
+ metadata.gz: 601c5b3f678c4b70f6a9b5466230a445f41bbb6c944a3479ce60ef252f189768
4
+ data.tar.gz: 8137ed6901d3b33f93b18d1300ecf548eec2bb5e90cb59410d7614060e80d73d
5
5
  SHA512:
6
- metadata.gz: 5d5ccebc49daf312d199a0d0eede813f80966dadb04654ea49345d408b4708b520208e3dd5018dba99eacb815c93ac735fd562691f5a0ddbb88acb725ea86221
7
- data.tar.gz: dcdcd70b597f09f91ccf43efed3f7ebd55e1389275e9701598e015e47a759089ed8003933e17c56fbe643cbcf26df69399eca1472c3be9ba06585e3f2ffdaddc
6
+ metadata.gz: 6313bfced4a79586e71d32ee58c4e216a6a7f96b1e0910fb066f877bc3c0465fb0408bcf1d5690f630472f92584f27209ed87a1c686bae20dae1116b2fd654cc
7
+ data.tar.gz: c950b0d74657b7b9965b00a7cd6274bbe62ce5fb827e20a6d076980a4b168f19a215fc3f3900a817422cc036e57698f7ef5ae77fbcdc76a7f41793421889dd66
data/CHANGELOG.md CHANGED
@@ -6,6 +6,73 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.2.0] - 2026-08-19
10
+
11
+ ### Changed — evidence is kept indefinitely by default
12
+
13
+ - **The default retention posture is now indefinite.** A policy that never
14
+ says `retain_with` runs under a new built-in retention class,
15
+ `evidence_kept_indefinitely`: no deletion clock on the core event, none on
16
+ any request evidence. Previously such a policy refused to boot. The
17
+ direction is deliberate: keeping is reversible — a reviewed disposition can
18
+ always run later — while deletion is not, and the day contractual evidence
19
+ matters is usually years past every convenient schedule. Deletion is the
20
+ explicit, reviewed opt-in it always was: declare a class with clocks and
21
+ name it on the policy.
22
+ - **A retention class may keep the core event forever.** New DSL verb
23
+ `retain_core_event_indefinitely` says the default out loud; omitting the
24
+ core-event rule now means the same thing instead of raising. Snapshots
25
+ record `{"indefinite" => true}`, the privacy inventory reports
26
+ `{"kind" => "indefinite"}`, events under such a class freeze no deadline,
27
+ and the retention planner never lists them as due — on any horizon.
28
+ - **Registries can carry built-in seeds.** `Registry#clear` (every reload)
29
+ now returns a seeded registry to its built-ins instead of to nothing, which
30
+ is what keeps the default retention class alive across `to_prepare`.
31
+
32
+ ### Documentation
33
+
34
+ - The README installs from rubygems.org (`gem "clickwrap"`), documents the
35
+ new retention default, and shows HTML pages and runtime `resolver:` sources
36
+ for legal documents alongside Markdown.
37
+
38
+ ## [0.1.1] - 2026-08-19
39
+
40
+ ### Fixed — the composed sentence in a language that declines its articles
41
+
42
+ - **Per-key sentence fragments.** Spanish cannot say "acepto %{documents}" for
43
+ every document — the article agrees with the noun ("los Términos", "la
44
+ Política"). The composer now looks up
45
+ `clickwrap.sentence.fragments.<kind>.<key>` before falling back to the
46
+ per-kind template, and the gem ships Spanish defaults for its standard keys.
47
+ An application adds its own in its locale files; no DSL change.
48
+ - **One opening capital.** Fragment templates are written lowercase for the
49
+ middle of a sentence; the composer capitalizes exactly one letter — the
50
+ first — once, at build time, so the signed manifest and the rendered HTML
51
+ can never disagree. The Spanish signup now reads
52
+ "Acepto los Términos y Condiciones y doy por recibida la Política de
53
+ Privacidad." instead of "Acepto Términos y Condiciones y He recibido
54
+ Política de Privacidad."
55
+ - **One voice, composed or itemized.** The Spanish composed acknowledgment now
56
+ says "doy por recibida" — the same words as the itemized default statement —
57
+ instead of the flatter "he recibido". The same act reads the same way in
58
+ either rendering. (Still never "he leído": the evidence records an
59
+ affirmative act on an offered notice, not that anyone read it. A host that
60
+ wants the first-person read-declaration owns that wording via its locale
61
+ files — see the next fix.)
62
+ - **Host locale overrides actually win now.** The engine appended its locale
63
+ files to `app.config.i18n.load_path` on top of Rails's automatic engine
64
+ locale loading. Railties paths are unshifted ahead of that list, so the
65
+ appended copy landed AFTER the host's own locale files — and every host
66
+ override of a gem key silently lost to the gem's default. The manual append
67
+ is gone; Rails::Engine's own `:add_locales` ordering (gem first, host last)
68
+ is the contract, and a test now pins it.
69
+ - **Checkbox optical alignment.** The box was mathematically centred on the
70
+ first line and still read as floating high: Latin text carries its mass
71
+ between cap-height and baseline, below the line box's midpoint. The offset
72
+ gains an optical eighth of an em, calibrated against rendered screenshots.
73
+
74
+ ## [0.1.0] - 2026-08-19
75
+
9
76
  ### Changed — the signup clickwrap is one line
10
77
 
11
78
  - **`form.clickwrap` renders ONE checkbox carrying ONE sentence** whenever every
data/README.md CHANGED
@@ -72,6 +72,16 @@ Clickwrap.document :handbook,
72
72
  from: Rails.root.join("app/content/legal/handbook.pdf")
73
73
  ```
74
74
 
75
+ Markdown is a convention here, not a requirement. `from:` takes HTML pages (`.html`/`.htm`), plain text, JSON, and PDF just as readily — the media type is inferred from the extension or named with `media_type:` — and an HTML source is sanitized at publish through the same safe-list every rendering passes (semantic tags survive; scripts, styles, and event handlers never become part of legal evidence). A page that only exists at runtime — an ERB view, a CMS entry — publishes through a `resolver:`, a callable that hands over the exact bytes at publish time:
76
+
77
+ ```ruby
78
+ Clickwrap.document :terms,
79
+ version: "2026-08-15",
80
+ media_type: "text/html",
81
+ resolver: ->(definition) { ApplicationController.render(template: "legal/terms", layout: false) },
82
+ link: "/legal/terms"
83
+ ```
84
+
75
85
  #### Reading that front matter yourself: `Clickwrap::FrontMatter`
76
86
 
77
87
  Your own pages usually need the same two answers, and it is the same block, so use the same reader rather than writing a third one:
@@ -146,7 +156,7 @@ Add the gem and run the installer:
146
156
 
147
157
  ```ruby
148
158
  # Gemfile
149
- gem "clickwrap", github: "rameerez/clickwrap"
159
+ gem "clickwrap"
150
160
  ```
151
161
 
152
162
  ```bash
@@ -319,6 +329,8 @@ other render.
319
329
 
320
330
  ## How it works
321
331
 
332
+ Most Rails apps treat "user agreed" as `accepted_terms_at` plus a checkbox. That's enough until you have to answer: which exact text, which version, what the button said, whether the box started unchecked, and whether the action happened without that evidence.
333
+
322
334
  Most apps eventually accumulate an `accepted_terms_at` column, a `terms_version` string, a few hidden form fields, an `after_create` callback, and some IP columns. Each part looks reasonable alone. Together they produce partial writes, client-owned policy decisions, mutable history, and evidence only the original engineer can explain.
323
335
 
324
336
  `clickwrap` replaces that plumbing with one coherent primitive:
@@ -752,13 +764,17 @@ The [request evidence guide](guides/request-evidence.md) covers every field, the
752
764
 
753
765
  ## Retention, deletion, and legal holds
754
766
 
755
- Every policy chooses an application-defined retention class:
767
+ **By default, evidence is kept indefinitely.** A policy that never says `retain_with` runs under the built-in `evidence_kept_indefinitely` class: no deletion clock on the core event, none on any request evidence. The direction is deliberate — keeping is reversible (a reviewed disposition can always run later), deletion is not, and the day contractual evidence matters is usually years past every convenient schedule. Deletion is the explicit, opt-in decision:
756
768
 
757
769
  ```ruby
758
770
  Clickwrap.retention :ordinary_agreement_evidence do
759
- retain_core_event_for 6.years
760
- delete_recorded_ip_address_after 90.days
761
- delete_recorded_browser_user_agent_after 90.days
771
+ retain_core_event_indefinitely # the default, said out loud
772
+ delete_recorded_ip_address_after 6.years
773
+ delete_recorded_browser_user_agent_after 6.years
774
+ end
775
+
776
+ Clickwrap.retention :short_lived_marketing_evidence do
777
+ retain_core_event_for 6.years # a reviewed deletion schedule, opt-in
762
778
  end
763
779
  ```
764
780
 
@@ -88,14 +88,18 @@
88
88
  .clickwrap-statement__checkbox,
89
89
  .clickwrap-statement__radio {
90
90
  /* Sized in em so the box matches the sentence it belongs to instead of
91
- towering over small text, and optically centered against the FIRST line
92
- of the label: (line-height minus box) halved. Flex `baseline` cannot do
93
- this a checkbox aligns its bottom edge to the baseline and ends up
94
- floating high. The rem fallback covers engines without the lh unit. */
91
+ towering over small text, and aligned against the FIRST line of the
92
+ label: (line-height minus box) halved, plus an optical eighth of an em.
93
+ The mathematical centre is not the visual one Latin text carries its
94
+ mass between cap-height and baseline, below the line box's midpoint, so
95
+ a box centred by arithmetic reads as floating high (calibrated against
96
+ rendered screenshots at 400%). Flex `baseline` is still worse: a
97
+ checkbox aligns its bottom edge to the baseline and floats higher. The
98
+ em fallback covers engines without the lh unit. */
95
99
  width: 1em;
96
100
  height: 1em;
97
- margin: 0.2em 0 0;
98
- margin-top: calc((1lh - 1em) / 2);
101
+ margin: 0.325em 0 0;
102
+ margin-top: calc((1lh - 1em) / 2 + 0.125em);
99
103
  flex: 0 0 auto;
100
104
  accent-color: var(--clickwrap-focus);
101
105
  }
@@ -22,13 +22,29 @@ es:
22
22
  acknowledgment:
23
23
  privacy_notice: "Doy por recibida la Política de Privacidad."
24
24
 
25
- # La frase de una sola línea. En español el artículo concuerda con el
26
- # documento ("los Términos", "la Política"), y solo la aplicación sabe qué
27
- # documento nombra, así que el artículo vive en la etiqueta del enlace y no
28
- # en estas plantillas — por eso `documents_joiner` es " y " y no " y el ".
25
+ # La frase de una sola línea. Las plantillas se escriben en minúscula y
26
+ # PARA EL MEDIO de la frase (" y he recibido …"): la mayúscula inicial la
27
+ # pone el compositor, una sola vez, al primer fragmento.
28
+ #
29
+ # En español el artículo concuerda con el documento ("los Términos", "la
30
+ # Política"), así que cada clave estándar lleva su propia plantilla bajo
31
+ # `fragments`, con el artículo correcto. Una aplicación con documentos
32
+ # propios añade aquí los suyos (p. ej.
33
+ # `fragments.agreement.contrato_marco: "acepto el %{documents}"`); sin
34
+ # plantilla propia se usa la genérica, que es neutra y correcta aunque
35
+ # menos idiomática.
29
36
  sentence:
30
- agreement: "Acepto %{documents}"
31
- acknowledgment: "He recibido %{documents}"
37
+ fragments:
38
+ agreement:
39
+ terms: "acepto los %{documents}"
40
+ acknowledgment:
41
+ # La misma voz que la declaración detallada ("Doy por recibida la
42
+ # Política de Privacidad."): el mismo acto debe leerse igual
43
+ # compuesto que detallado. Nunca "he leído": la evidencia registra
44
+ # un acto afirmativo sobre un aviso ofrecido, no que alguien leyera.
45
+ privacy_notice: "doy por recibida la %{documents}"
46
+ agreement: "acepto %{documents}"
47
+ acknowledgment: "he recibido %{documents}"
32
48
  documents_joiner: " y "
33
49
  joiner: " y "
34
50
  terminator: "."
@@ -27,6 +27,13 @@ module Clickwrap
27
27
  assign_rule!(:core_event, duration:)
28
28
  end
29
29
 
30
+ # The default, said out loud. Omitting the core-event rule means the same
31
+ # thing, but a retention class somebody will read in review is better off
32
+ # carrying the decision in words.
33
+ def retain_core_event_indefinitely
34
+ assign_rule!(:core_event, indefinite: true)
35
+ end
36
+
30
37
  # For obligations a duration cannot express — "five years, or three years
31
38
  # after this contract is liquidated, whichever is later". The named
32
39
  # calculation is registered by the host on the configuration object, and
@@ -66,7 +73,7 @@ module Clickwrap
66
73
 
67
74
  private
68
75
 
69
- def assign_rule!(part, duration: nil, host_event_name: nil)
76
+ def assign_rule!(part, duration: nil, host_event_name: nil, indefinite: false)
70
77
  if @rules.key?(part)
71
78
  raise DefinitionError,
72
79
  "Retention class #{@key} declares #{part} more than once. Keep one reviewed " \
@@ -74,7 +81,7 @@ module Clickwrap
74
81
  "deletion deadline."
75
82
  end
76
83
 
77
- @rules[part] = RetentionClass::Rule.new(part:, duration:, host_event_name:)
84
+ @rules[part] = RetentionClass::Rule.new(part:, duration:, host_event_name:, indefinite:)
78
85
  end
79
86
 
80
87
  def method_missing(name, *_arguments, **_options)
@@ -124,12 +124,15 @@ module Clickwrap
124
124
  end
125
125
  end
126
126
 
127
- # Ship the gem's locale files. Host locale files with the same keys override
128
- # these automatically (I18n's load order puts the app last), which is how a
129
- # host rewords a statement without forking a view.
130
- initializer "clickwrap.locales" do |app|
131
- app.config.i18n.load_path += Dir[root.join("config", "locales", "**", "*.{rb,yml}").to_s]
132
- end
127
+ # The gem's locale files ship through Rails::Engine's own :add_locales
128
+ # (every engine's `config/locales` is picked up automatically) and NOT
129
+ # through a manual `app.config.i18n.load_path +=` on top of it. The manual
130
+ # append is not merely redundant: railties paths are UNSHIFTED before
131
+ # everything in `load_path`, so an appended copy of these files lands
132
+ # AFTER the host's own locales and quietly overrides them. Load order is
133
+ # the whole contract here — gem first, host last — because a host rewords
134
+ # a statement by shipping the same key in its own locale file, and the
135
+ # host's counsel must always get the last word.
133
136
 
134
137
  initializer "clickwrap.assets" do |app|
135
138
  app.config.assets.paths << root.join("app/assets/stylesheets") if app.config.respond_to?(:assets)
@@ -557,7 +557,9 @@ module Clickwrap
557
557
  return if retain_core_event_until.present? || retention_rule_name.present?
558
558
 
559
559
  rule = Clickwrap.retention_class!(retention_class_key).rule_for(:core_event)
560
- return if rule.nil?
560
+ # An indefinite rule freezes nothing: no deadline, no named calculation.
561
+ # The blank schedule plus the class key IS the recorded decision.
562
+ return if rule.nil? || rule.indefinite?
561
563
 
562
564
  if rule.duration?
563
565
  self.retain_core_event_until = recorded_at_by_server + rule.duration
@@ -29,7 +29,10 @@ module Clickwrap
29
29
  authority_rule: nil, options: {})
30
30
  @key = key.to_s
31
31
  @statements = statements.freeze
32
- @retention_class_key = retention_class_key&.to_s
32
+ # A policy that never says `retain_with` keeps its evidence indefinitely
33
+ # under the built-in class — the reversible default. Deletion clocks are
34
+ # the reviewed opt-in, declared with `Clickwrap.retention` and named here.
35
+ @retention_class_key = (retention_class_key || DEFAULT_RETENTION_CLASS_KEY).to_s
33
36
  @request_evidence = request_evidence || RequestEvidencePolicy.new(policy_key: @key)
34
37
  @persist_presentations_for = persist_presentations_for
35
38
  @persist_presentations_because = persist_presentations_because
@@ -238,12 +241,10 @@ module Clickwrap
238
241
  end
239
242
 
240
243
  def validate_retention!
241
- return if retention_class_key
242
-
243
- raise DefinitionError,
244
- "Policy #{key} has no retention class. Add `retain_with :some_class` and define " \
245
- "that class with `Clickwrap.retention`. Clickwrap will not default your evidence " \
246
- "to forever, and it will not pick a period for you."
244
+ # Always present: the initializer defaults a silent policy to the
245
+ # built-in evidence_kept_indefinitely class. Kept as a method so the
246
+ # validation order below still reads as the full checklist.
247
+ raise DefinitionError, "Policy #{key} has no retention class." if retention_class_key.blank?
247
248
  end
248
249
 
249
250
  def validate_persisted_presentations!
@@ -383,6 +383,12 @@ module Clickwrap
383
383
 
384
384
  def build_combined(composable, fragments, connectives)
385
385
  control = composable.first
386
+ # Fragment templates are written for the middle of a sentence ("he
387
+ # recibido la …"), because every fragment but the first sits there. The
388
+ # sentence's opening capital is applied here, once, so the signed
389
+ # manifest text and the rendered HTML can never disagree about it.
390
+ fragments = [fragments.first.with(prefix: fragments.first.prefix.sub(/\A\p{Ll}/, &:upcase))] +
391
+ fragments[1..]
386
392
 
387
393
  Combined.new(
388
394
  sentence: fragments.map(&:to_text).join(connectives[:joiner]) + connectives[:terminator],
@@ -433,7 +439,14 @@ module Clickwrap
433
439
  end
434
440
 
435
441
  def sentence_fragment(statement, connectives)
436
- template = sentence_text(statement.kind)
442
+ # A language that needs to agree with its nouns — an article, a gendered
443
+ # participle — cannot say "acepto %{documents}" for every document. A
444
+ # per-key template carries those words for THIS document
445
+ # (clickwrap.sentence.fragments.agreement.terms: "acepto los
446
+ # %{documents}"); the per-kind template stays the fallback for keys
447
+ # nobody translated specifically.
448
+ template = sentence_text("fragments.#{statement.kind}.#{statement.key}") ||
449
+ sentence_text(statement.kind)
437
450
  return nil if template.nil?
438
451
 
439
452
  prefix, suffix = template.split(DOCUMENTS_PLACEHOLDER, 2)
@@ -220,6 +220,7 @@ module Clickwrap
220
220
  # difference between "not due yet" and "nothing can ever say when".
221
221
  def describe_rule(rule)
222
222
  return nil if rule.nil?
223
+ return { "kind" => "indefinite" } if rule.indefinite?
223
224
  return { "kind" => "duration", "seconds" => rule.duration.to_i } if rule.duration?
224
225
 
225
226
  {
@@ -9,11 +9,18 @@ module Clickwrap
9
9
  # loading the declaration files again. Seeing the same key twice inside one
10
10
  # load is therefore always ambiguous and is refused instead of letting file
11
11
  # order silently decide which policy governs a production action.
12
+ #
13
+ # A registry may carry a seed: built-in entries that are its floor rather
14
+ # than its contents. Clearing re-runs the seed, so a reload returns to the
15
+ # built-ins, never to nothing — which is what lets a gem-shipped default
16
+ # (the indefinite retention class) survive every `to_prepare`.
12
17
  class Registry
13
- def initialize(kind)
18
+ def initialize(kind, &seed)
14
19
  @kind = kind
15
20
  @entries = {}
16
21
  @mutex = Mutex.new
22
+ @seed = seed
23
+ @seed&.call(self)
17
24
  end
18
25
 
19
26
  attr_reader :kind
@@ -47,7 +54,12 @@ module Clickwrap
47
54
  def size = @entries.size
48
55
  def empty? = @entries.empty?
49
56
  def each(&) = @entries.each_value(&)
50
- def clear = @mutex.synchronize { @entries.clear }
57
+
58
+ def clear
59
+ @mutex.synchronize { @entries.clear }
60
+ @seed&.call(self)
61
+ self
62
+ end
51
63
 
52
64
  include Enumerable
53
65
  end
@@ -131,6 +131,15 @@ module Clickwrap
131
131
 
132
132
  rule = retention_class.rule_for(:core_event)
133
133
  return Eligibility.new(rule: nil, unresolved_reason: "No core-event rule is defined.") if rule.nil?
134
+ if rule.indefinite?
135
+ # Never due, by design — the reason names the decision, not a gap.
136
+ # (Scopes skip indefinite classes, so this branch is the answer for
137
+ # anyone asking about one event directly, and a guard for the
138
+ # applier's re-check.)
139
+ return Eligibility.new(rule: "indefinite",
140
+ unresolved_reason: "This event's retention class keeps the core " \
141
+ "event indefinitely; it is never due.")
142
+ end
134
143
  return resolve_host_event(rule.host_event_name, event) if rule.host_event?
135
144
 
136
145
  Eligibility.new(eligible_at: event.recorded_at_by_server + rule.duration,
@@ -249,7 +258,7 @@ module Clickwrap
249
258
 
250
259
  Clickwrap.retention_classes.each do |retention_class|
251
260
  rule = retention_class.rule_for(:core_event)
252
- next if rule.nil?
261
+ next if rule.nil? || rule.indefinite?
253
262
 
254
263
  unscheduled = base_events.where(retain_core_event_until: nil, retention_class_key: retention_class.key)
255
264
 
@@ -5,14 +5,19 @@ module Clickwrap
5
5
  # kept, and what triggers the clock.
6
6
  #
7
7
  # Clickwrap.retention :ordinary_agreement_evidence do
8
- # retain_core_event_for 6.years
9
- # delete_recorded_ip_address_after 90.days
10
- # delete_recorded_browser_user_agent_after 90.days
11
- # delete_recorded_ip_geolocation_after 90.days
8
+ # retain_core_event_indefinitely
9
+ # delete_recorded_ip_address_after 6.years
10
+ # delete_recorded_browser_user_agent_after 6.years
12
11
  # end
13
12
  #
14
- # Clickwrap does not choose these periods and cannot tell you whether yours
15
- # are right. What it does is make a reviewed decision executable and
13
+ # The default — for any part not given a rule, the core event included is
14
+ # to keep the evidence indefinitely. That direction is deliberate: keeping is
15
+ # reversible (a reviewed disposition can always run later) while deletion is
16
+ # not, and the day contractual evidence matters is usually years away.
17
+ # Deletion is therefore the explicit, reviewed act, never a default.
18
+ #
19
+ # Clickwrap does not choose deletion periods and cannot tell you whether
20
+ # yours are right. What it does is make a reviewed decision executable and
16
21
  # auditable, keep the core event's schedule separate from the optional
17
22
  # personal request evidence, and delegate event-based or "later of" rules to
18
23
  # a named host calculation. The host owns that calculation because a fixed
@@ -21,19 +26,24 @@ module Clickwrap
21
26
  class RetentionClass
22
27
  PARTS = %i[core_event ip_address browser_user_agent ip_geolocation].freeze
23
28
 
24
- # A rule is either a duration from the event's server-recorded time, or the
25
- # name of a host-registered calculation that may depend on domain state and
26
- # may not be resolvable yet.
27
- Rule = Data.define(:part, :duration, :host_event_name) do
28
- def initialize(part:, duration: nil, host_event_name: nil)
29
+ # A rule is a duration from the event's server-recorded time, the name of a
30
+ # host-registered calculation that may depend on domain state and may not
31
+ # be resolvable yet — or the explicit decision to keep the part forever.
32
+ Rule = Data.define(:part, :duration, :host_event_name, :indefinite) do
33
+ def initialize(part:, duration: nil, host_event_name: nil, indefinite: false)
29
34
  super
30
35
  end
31
36
 
32
37
  def duration? = !duration.nil?
33
38
  def host_event? = !host_event_name.nil?
39
+ def indefinite? = indefinite
34
40
 
35
41
  def to_snapshot
36
- { "duration_seconds" => duration&.to_i, "host_event" => host_event_name&.to_s }.compact
42
+ {
43
+ "duration_seconds" => duration&.to_i,
44
+ "host_event" => host_event_name&.to_s,
45
+ "indefinite" => (true if indefinite)
46
+ }.compact
37
47
  end
38
48
  end
39
49
 
@@ -41,6 +51,12 @@ module Clickwrap
41
51
 
42
52
  def initialize(key:, rules:)
43
53
  @key = key.to_s
54
+ # A part with no declared rule is kept indefinitely. For the core event
55
+ # that default is made explicit here, so every consumer — the planner,
56
+ # the privacy inventory, the snapshot on a plan — sees a reviewed answer
57
+ # ("indefinite") rather than a silence it must interpret.
58
+ rules = rules.dup
59
+ rules[:core_event] ||= Rule.new(part: :core_event, indefinite: true)
44
60
  @rules = rules.freeze
45
61
 
46
62
  validate!
@@ -67,10 +83,11 @@ module Clickwrap
67
83
  end
68
84
 
69
85
  rules.each_value do |rule|
70
- if rule.duration? == rule.host_event?
86
+ if [rule.duration?, rule.host_event?, rule.indefinite?].count(true) != 1
71
87
  raise DefinitionError,
72
88
  "Retention class #{key} must give #{rule.part} exactly one schedule: a " \
73
- "duration or a named host calculation, not both or neither."
89
+ "duration, a named host calculation, or indefinite never a combination " \
90
+ "and never none."
74
91
  end
75
92
 
76
93
  if rule.host_event? && rule.host_event_name.to_s.strip.empty?
@@ -85,13 +102,6 @@ module Clickwrap
85
102
  "Retention class #{key} keeps #{rule.part} for #{rule.duration.inspect}, which is " \
86
103
  "not a period."
87
104
  end
88
-
89
- return if rules.key?(:core_event)
90
-
91
- raise DefinitionError,
92
- "Retention class #{key} never says how long to keep the core event. Use " \
93
- "`retain_core_event_for 6.years` or `retain_core_event_until :your_host_event`. " \
94
- "Clickwrap has no forever default, and it will not pick a period for you."
95
105
  end
96
106
  end
97
107
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clickwrap
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
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
data/lib/clickwrap.rb CHANGED
@@ -58,6 +58,10 @@ require_relative "clickwrap/engine" if defined?(Rails::Engine)
58
58
  # long you must keep anything. Those belong to the application and its counsel,
59
59
  # and no configuration flag here can stand in for them.
60
60
  module Clickwrap
61
+ # The retention class every policy gets unless it names its own with
62
+ # `retain_with`: evidence kept indefinitely, deletion always an explicit,
63
+ # reviewed act. Keeping is reversible; deleting is not.
64
+ DEFAULT_RETENTION_CLASS_KEY = "evidence_kept_indefinitely"
61
65
  DOCUMENT_OPTIONS = %i[
62
66
  version locale media_type effective_at tenant from content resolver renderer link
63
67
  ].freeze
@@ -109,7 +113,19 @@ module Clickwrap
109
113
 
110
114
  def documents = @documents ||= Registry.new(:document)
111
115
  def policies = @policies ||= Registry.new(:policy)
112
- def retention_classes = @retention_classes ||= Registry.new(:retention_class)
116
+
117
+ # The registry is seeded with one built-in class: evidence kept
118
+ # indefinitely, nothing scheduled for deletion. It exists so a policy that
119
+ # never says `retain_with` has a real, inspectable retention class instead
120
+ # of a hole — keeping is the reversible default; deletion is the reviewed
121
+ # opt-in. A host wanting deletion clocks declares its own class and names
122
+ # it on the policy. The seed survives every reload (see Registry#clear).
123
+ def retention_classes
124
+ @retention_classes ||= Registry.new(:retention_class) do |registry|
125
+ registry.register(DEFAULT_RETENTION_CLASS_KEY,
126
+ RetentionClass.new(key: DEFAULT_RETENTION_CLASS_KEY, rules: {}))
127
+ end
128
+ end
113
129
 
114
130
  # Declares one immutable document version. Declaring it does not publish it:
115
131
  # `bin/rails clickwrap:publish` reads the bytes once, digests them, and
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: clickwrap
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - rameerez