fosm-rails 0.2.3 → 0.2.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: 50ac8659af44dfad52d33f333001e6b3b846cfbb635e56534366ca371631501a
4
- data.tar.gz: 5d727e837eefe74fa85677199b6cbc78b7e3df28fe33f8275e4c8bd93900383b
3
+ metadata.gz: 41f74b1b8b8a51fde040030b20b00e076a302be054bb6f7a5cd7dff22201c889
4
+ data.tar.gz: 6030364793d6d6cd82ceef77f257c2998771d12108e433011e4c61e61eb4cf13
5
5
  SHA512:
6
- metadata.gz: e3adb965398f95f843d6d58f39f6b1b54d6c0084c46761b0f30678c7484bf6ca6c4e182e21ce5504b391cf147d9495da4d0d1fcad3ec4992693d7129c40c80c0
7
- data.tar.gz: 04e1e7595f6dc9c211d7eb923ef167e80d612fab71149f1b5624cc5cbffd46ed64f16d909efd8a4c4a418a4895ba02fba39926fd98f1bcb1a09a028b06e7ca3e
6
+ metadata.gz: 92649aff68edd9a5d932c3e674fe45628a08ab98334faa1a0477c464a0029671322ba643695461a3aef866d8f38fe3cf0304d23901b0c3a4c84bd2318276ae51
7
+ data.tar.gz: a9f6ba8159d2b61e2519c7758127149b5d83288c30f99ad715664ae632cfcde6f778c7944593e2b30d9773af7412fa5e52cafde06bf7b788af0c126b219afade
data/README.md CHANGED
@@ -337,6 +337,7 @@ The engine mounts an admin interface at `/fosm/admin` (access controlled by `con
337
337
  - **Role assignments** (`/fosm/admin/roles`) — grant/revoke roles, view declared roles per app, browse immutable access event audit trail; accessible only to `config.admin_authorize` actors
338
338
  - **Agent explorer** (`/fosm/admin/apps/:slug/agent`) — the auto-generated tool catalog for the app's AI agent, a direct tool tester (no LLM required), and the system prompt injected into agents
339
339
  - **Agent chat** (`/fosm/admin/apps/:slug/agent/chat`) — live multi-turn chat with the agent; see tool calls, thoughts, and state changes in real time
340
+ - **Data retention** (`/fosm/admin/data_retention`) — configurable retention policies (default 10 years) with a dashboard showing purge-eligible records per model. Supports single-record and bulk purge with safe-to-purge guards; transition logs are never deleted.
340
341
  - **Transition log** — complete audit trail, filterable by app / event / actor (human vs AI agent)
341
342
  - **Webhooks** — configure HTTP callbacks for any FOSM event (with HMAC-SHA256 signing)
342
343
  - **Settings** — LLM provider key status, engine configuration overview
@@ -0,0 +1,125 @@
1
+ module Fosm
2
+ module Admin
3
+ # Admin dashboard for data retention policy enforcement.
4
+ #
5
+ # Shows all archival-eligible FOSM models (terminal state containing "archiv"
6
+ # + archived_at column) and lets admins review and purge records that have
7
+ # exceeded the configured retention window.
8
+ #
9
+ # All destructive actions run asynchronously via DataRetentionPurgeJob so
10
+ # the request/response cycle is never blocked by bulk deletes.
11
+ class DataRetentionController < BaseController
12
+ PER_PAGE = 50
13
+
14
+ # GET /fosm/admin/data_retention
15
+ # Lists every archival-eligible model with counts.
16
+ def index
17
+ @retention_days = Fosm.config.data_retention_days
18
+ @cutoff_date = Fosm::DataRetention.retention_cutoff_date
19
+
20
+ @eligible_models = Fosm::DataRetention.archival_eligible_models.map do |model_class|
21
+ slug = Fosm::Registry.all.find { |_s, klass| klass.name == model_class.name }&.first
22
+ {
23
+ model_class: model_class,
24
+ slug: slug,
25
+ name: model_class.name.demodulize.titleize,
26
+ archival_states: Fosm::DataRetention.archival_states_for(model_class),
27
+ total_in_archive: Fosm::DataRetention.total_in_archival_state(model_class),
28
+ eligible_for_purge: Fosm::DataRetention.total_eligible_for_purge(model_class)
29
+ }
30
+ end
31
+ end
32
+
33
+ # GET /fosm/admin/data_retention/:id (:id = model slug, e.g. "faas_account")
34
+ # Paginated list of purge-eligible records for one model.
35
+ def show
36
+ @model_class = resolve_eligible_model!(params[:id])
37
+ @slug = params[:id]
38
+ @name = @model_class.name.demodulize.titleize
39
+
40
+ @retention_days = Fosm.config.data_retention_days
41
+ @cutoff_date = Fosm::DataRetention.retention_cutoff_date
42
+ @archival_states = Fosm::DataRetention.archival_states_for(@model_class)
43
+
44
+ @page = [params[:page].to_i, 1].max
45
+ @total = Fosm::DataRetention.total_eligible_for_purge(@model_class)
46
+ @total_pages = [(@total.to_f / PER_PAGE).ceil, 1].max
47
+ @records = Fosm::DataRetention.records_eligible_for_purge(
48
+ @model_class, page: @page, per_page: PER_PAGE
49
+ )
50
+ end
51
+
52
+ # POST /fosm/admin/data_retention/:id/purge_record
53
+ # Enqueues a single-record purge. params[:record_id] identifies the row.
54
+ def purge_record
55
+ @model_class = resolve_eligible_model!(params[:id])
56
+ record = @model_class.find_by(id: params[:record_id])
57
+
58
+ unless record
59
+ redirect_to fosm.admin_data_retention_path(params[:id]),
60
+ alert: "Record not found — it may have already been purged."
61
+ return
62
+ end
63
+
64
+ unless Fosm::DataRetention.safe_to_purge?(record)
65
+ redirect_to fosm.admin_data_retention_path(params[:id]),
66
+ alert: "Record ##{record.id} is not eligible for purge " \
67
+ "(within the #{Fosm.config.data_retention_days}-day retention window)."
68
+ return
69
+ end
70
+
71
+ Fosm::DataRetentionPurgeJob.perform_later(
72
+ model_class_name: @model_class.name,
73
+ record_id: record.id.to_s,
74
+ purged_by_label: current_admin_label
75
+ )
76
+
77
+ redirect_to fosm.admin_data_retention_path(params[:id]),
78
+ notice: "Record ##{record.id} has been queued for purge."
79
+ end
80
+
81
+ # POST /fosm/admin/data_retention/:id/purge_all_expired
82
+ # Enqueues a bulk purge of all retention-expired records for this model.
83
+ def purge_all_expired
84
+ @model_class = resolve_eligible_model!(params[:id])
85
+ total = Fosm::DataRetention.total_eligible_for_purge(@model_class)
86
+
87
+ if total.zero?
88
+ redirect_to fosm.admin_data_retention_path(params[:id]),
89
+ notice: "No records are eligible for purge."
90
+ return
91
+ end
92
+
93
+ Fosm::DataRetentionPurgeJob.perform_later(
94
+ model_class_name: @model_class.name,
95
+ bulk: true,
96
+ purged_by_label: current_admin_label
97
+ )
98
+
99
+ redirect_to fosm.admin_data_retention_path(params[:id]),
100
+ notice: "#{total} record(s) queued for bulk purge. This runs asynchronously."
101
+ end
102
+
103
+ private
104
+
105
+ def resolve_eligible_model!(slug)
106
+ model_class = Fosm::Registry.find(slug)
107
+ raise ActionController::RoutingError, "Unknown FOSM model: #{slug}" unless model_class
108
+ unless Fosm::DataRetention.archival_eligible?(model_class)
109
+ raise ActionController::RoutingError,
110
+ "Model '#{slug}' is not archival-eligible " \
111
+ "(needs a terminal state containing 'archiv' AND an archived_at column)."
112
+ end
113
+ model_class
114
+ end
115
+
116
+ def current_admin_label
117
+ user = instance_exec(&Fosm.config.current_user_method)
118
+ return "admin" unless user
119
+ user.respond_to?(:email) ? user.email : user.to_s
120
+ rescue
121
+ "admin"
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,129 @@
1
+ module Fosm
2
+ # Safely purges FOSM records that have exceeded the configured data retention
3
+ # window. Designed to be triggered from the Data Archival admin UI.
4
+ #
5
+ # == Safety guarantees
6
+ #
7
+ # 1. Re-checks archival eligibility of the model class on entry.
8
+ # 2. Re-checks +Fosm::DataRetention.safe_to_purge?+ per record inside the job
9
+ # — the controller's pre-check is advisory only; the retention window or
10
+ # record state could change between the UI request and job execution.
11
+ # 3. Missing records are silently skipped (already purged — idempotent).
12
+ # 4. Records within the retention window are NEVER deleted, even when
13
+ # explicitly enqueued — this is the absolute last line of defence.
14
+ # 5. +fosm_transition_logs+ rows are NOT deleted. The audit trail is
15
+ # preserved forever for compliance purposes.
16
+ # 6. Errors on individual records are logged, not re-raised, so a bulk job
17
+ # continues processing the remaining records.
18
+ # 7. Unknown or non-eligible model class names abort immediately without
19
+ # side-effects.
20
+ #
21
+ # == Usage
22
+ #
23
+ # # Single record
24
+ # Fosm::DataRetentionPurgeJob.perform_later(
25
+ # model_class_name: "Fosm::FaasAccount",
26
+ # record_id: "42",
27
+ # purged_by_label: current_user.email
28
+ # )
29
+ #
30
+ # # Bulk — purges every eligible record for the model
31
+ # Fosm::DataRetentionPurgeJob.perform_later(
32
+ # model_class_name: "Fosm::FaasAccount",
33
+ # bulk: true,
34
+ # purged_by_label: current_user.email
35
+ # )
36
+ class DataRetentionPurgeJob < Fosm::ApplicationJob
37
+ queue_as :default
38
+
39
+ # @param model_class_name [String] e.g. "Fosm::FaasAccount"
40
+ # @param record_id [String, nil] ID of a single record to purge
41
+ # @param bulk [Boolean] true → purge all eligible records
42
+ # @param purged_by_label [String] actor label for audit logging
43
+ def perform(model_class_name:, record_id: nil, bulk: false, purged_by_label: "system")
44
+ model_class = resolve_model_class(model_class_name)
45
+ return unless model_class
46
+
47
+ unless Fosm::DataRetention.archival_eligible?(model_class)
48
+ log_warn "#{model_class_name} is not archival-eligible. Purge aborted."
49
+ return
50
+ end
51
+
52
+ if bulk
53
+ purge_all_expired(model_class, purged_by_label)
54
+ elsif record_id.present?
55
+ purge_single(model_class, record_id.to_s, purged_by_label)
56
+ else
57
+ log_warn "Neither bulk: true nor record_id provided. Nothing to purge."
58
+ end
59
+ end
60
+
61
+ private
62
+
63
+ def resolve_model_class(name)
64
+ name.constantize
65
+ rescue NameError => e
66
+ log_error "Unknown model class '#{name}': #{e.message}"
67
+ nil
68
+ end
69
+
70
+ def purge_single(model_class, record_id, purged_by_label)
71
+ record = model_class.find_by(id: record_id)
72
+ unless record
73
+ log_warn "#{model_class.name}##{record_id} not found — already purged or never existed."
74
+ return
75
+ end
76
+
77
+ # CRITICAL: re-verify retention window even if the controller already checked.
78
+ # The window or state may have changed between the UI click and job execution.
79
+ unless Fosm::DataRetention.safe_to_purge?(record)
80
+ log_warn(
81
+ "#{model_class.name}##{record_id} is within the " \
82
+ "#{Fosm.config.data_retention_days}-day retention window or not in an " \
83
+ "archival state. Skipping."
84
+ )
85
+ return
86
+ end
87
+
88
+ log_info "Purging #{model_class.name}##{record_id} " \
89
+ "(archived_at: #{record.archived_at}, purged_by: #{purged_by_label})"
90
+ record.destroy!
91
+ log_info "Purged #{model_class.name}##{record_id} successfully."
92
+ rescue => e
93
+ log_error "Failed to purge #{model_class.name}##{record_id}: #{e.message}"
94
+ end
95
+
96
+ def purge_all_expired(model_class, purged_by_label)
97
+ purged = 0
98
+ skipped = 0
99
+
100
+ Fosm::DataRetention.all_eligible_for_purge(model_class).find_each do |record|
101
+ # Per-record safety check — never blindly trust the scope alone.
102
+ unless Fosm::DataRetention.safe_to_purge?(record)
103
+ log_warn "Skipping #{model_class.name}##{record.id} — within retention window."
104
+ skipped += 1
105
+ next
106
+ end
107
+
108
+ begin
109
+ record.destroy!
110
+ purged += 1
111
+ rescue => e
112
+ log_error "Failed to purge #{model_class.name}##{record.id}: #{e.message}"
113
+ skipped += 1
114
+ end
115
+ end
116
+
117
+ log_info "Bulk purge complete for #{model_class.name}: " \
118
+ "#{purged} purged, #{skipped} skipped (purged_by: #{purged_by_label})."
119
+ end
120
+
121
+ def log_info(msg) = logger.info("[Fosm::DataRetentionPurgeJob] #{msg}")
122
+ def log_warn(msg) = logger.warn("[Fosm::DataRetentionPurgeJob] #{msg}")
123
+ def log_error(msg) = logger.error("[Fosm::DataRetentionPurgeJob] #{msg}")
124
+
125
+ def logger
126
+ defined?(Rails) && Rails.respond_to?(:logger) ? Rails.logger : Logger.new($stdout)
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,68 @@
1
+ <div class="p-6 space-y-6">
2
+
3
+ <!-- Header -->
4
+ <div>
5
+ <h1 class="text-2xl font-bold text-gray-900">Data Archival &amp; Deletion</h1>
6
+ <p class="text-sm text-gray-500 mt-1">
7
+ Retention policy: <strong><%= @retention_days %> days</strong>
8
+ &mdash; records archived before
9
+ <strong><%= @cutoff_date.strftime("%b %d, %Y") %></strong>
10
+ are eligible for purge.
11
+ </p>
12
+ </div>
13
+
14
+ <!-- Compliance notice -->
15
+ <div class="bg-amber-50 border border-amber-200 rounded-lg p-4 text-sm text-amber-800 flex gap-3">
16
+ <span class="text-amber-500 text-lg leading-none">&#9888;</span>
17
+ <div>
18
+ <strong>Compliance notice:</strong> Purging permanently deletes business records.
19
+ Transition logs (<code class="bg-amber-100 px-1 rounded">fosm_transition_logs</code>)
20
+ are <em>always</em> preserved for audit compliance — only the source records are removed.
21
+ Configure <code class="bg-amber-100 px-1 rounded">config.data_retention_days</code>
22
+ in <code class="bg-amber-100 px-1 rounded">config/initializers/fosm.rb</code>.
23
+ </div>
24
+ </div>
25
+
26
+ <!-- Model cards -->
27
+ <% if @eligible_models.empty? %>
28
+ <div class="bg-gray-50 border border-gray-200 rounded-lg p-8 text-center">
29
+ <h2 class="font-semibold text-gray-700">No archival-eligible models found</h2>
30
+ <p class="text-sm text-gray-500 mt-2 max-w-md mx-auto">
31
+ A model is eligible when it has a terminal state containing
32
+ <code class="bg-gray-100 px-1 rounded">archiv</code>
33
+ (e.g. <code class="bg-gray-100 px-1 rounded">:archived</code>)
34
+ <em>and</em> an
35
+ <code class="bg-gray-100 px-1 rounded">archived_at</code>
36
+ datetime column on its table.
37
+ </p>
38
+ </div>
39
+ <% else %>
40
+ <div class="space-y-3">
41
+ <% @eligible_models.each do |model| %>
42
+ <div class="bg-white border border-gray-200 rounded-lg p-5 flex items-center justify-between gap-4">
43
+ <div class="flex-1 min-w-0">
44
+ <h2 class="font-semibold text-gray-900"><%= model[:name] %></h2>
45
+ <p class="text-xs text-gray-400 mt-0.5">
46
+ Archival states:
47
+ <% model[:archival_states].each do |s| %>
48
+ <span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs bg-gray-100 text-gray-600 font-mono"><%= s %></span>
49
+ <% end %>
50
+ &middot;
51
+ <%= model[:total_in_archive] %> total in archive
52
+ </p>
53
+ </div>
54
+ <div class="text-right shrink-0">
55
+ <p class="text-2xl font-bold <%= model[:eligible_for_purge] > 0 ? 'text-red-600' : 'text-gray-300' %>">
56
+ <%= model[:eligible_for_purge] %>
57
+ </p>
58
+ <p class="text-xs text-gray-500">eligible for purge</p>
59
+ </div>
60
+ <%= link_to "Manage &rarr;".html_safe,
61
+ fosm.admin_data_retention_path(model[:slug]),
62
+ class: "shrink-0 text-sm text-blue-600 hover:underline font-medium" %>
63
+ </div>
64
+ <% end %>
65
+ </div>
66
+ <% end %>
67
+
68
+ </div>
@@ -0,0 +1,124 @@
1
+ <div class="p-6 space-y-6">
2
+
3
+ <!-- Breadcrumb + header -->
4
+ <div>
5
+ <%= link_to "&larr; Data Archival".html_safe,
6
+ fosm.admin_data_retention_index_path,
7
+ class: "text-sm text-gray-400 hover:text-gray-600 mb-2 block" %>
8
+ <div class="flex items-start justify-between gap-4">
9
+ <div>
10
+ <h1 class="text-2xl font-bold text-gray-900"><%= @name %></h1>
11
+ <p class="text-sm text-gray-500 mt-1">
12
+ Records archived before
13
+ <strong><%= @cutoff_date.strftime("%b %d, %Y") %></strong>
14
+ (<%= @retention_days %>-day policy) &mdash;
15
+ <strong><%= @total %></strong> record(s) eligible for purge.
16
+ </p>
17
+ <p class="text-xs text-gray-400 mt-0.5">
18
+ Archival states:
19
+ <% @archival_states.each do |s| %>
20
+ <span class="inline-flex items-center px-1.5 py-0.5 rounded text-xs bg-gray-100 text-gray-600 font-mono"><%= s %></span>
21
+ <% end %>
22
+ </p>
23
+ </div>
24
+
25
+ <% if @total > 0 %>
26
+ <%= button_to fosm.purge_all_expired_admin_data_retention_path(@slug),
27
+ method: :post,
28
+ data: {
29
+ confirm: "This will permanently delete ALL #{@total} eligible #{@name} record(s). " \
30
+ "Transition logs will be preserved. This cannot be undone. Continue?"
31
+ },
32
+ class: "shrink-0 bg-red-600 text-white text-sm font-semibold px-4 py-2 rounded-lg hover:bg-red-700 transition" do %>
33
+ Purge All <%= @total %> Eligible
34
+ <% end %>
35
+ <% end %>
36
+ </div>
37
+ </div>
38
+
39
+ <!-- Records table -->
40
+ <% if @records.empty? %>
41
+ <div class="bg-gray-50 border border-gray-200 rounded-lg p-8 text-center">
42
+ <p class="text-gray-500 font-medium">No records eligible for purge</p>
43
+ <p class="text-xs text-gray-400 mt-1">
44
+ All archived records are within the <%= @retention_days %>-day retention window.
45
+ </p>
46
+ </div>
47
+ <% else %>
48
+ <div class="bg-white border border-gray-200 rounded-lg overflow-hidden">
49
+ <table class="w-full text-sm">
50
+ <thead class="bg-gray-50 text-gray-500 text-xs uppercase tracking-wide">
51
+ <tr>
52
+ <th class="px-4 py-3 text-left">ID</th>
53
+ <th class="px-4 py-3 text-left">State</th>
54
+ <th class="px-4 py-3 text-left">Archived At</th>
55
+ <th class="px-4 py-3 text-left">Retention Age</th>
56
+ <th class="px-4 py-3 text-right">Action</th>
57
+ </tr>
58
+ </thead>
59
+ <tbody class="divide-y divide-gray-100">
60
+ <% @records.each do |record| %>
61
+ <tr class="hover:bg-gray-50">
62
+ <td class="px-4 py-3 text-gray-600 font-mono text-xs">#<%= record.id %></td>
63
+ <td class="px-4 py-3">
64
+ <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-700">
65
+ <%= record.state %>
66
+ </span>
67
+ </td>
68
+ <td class="px-4 py-3 text-gray-600 text-xs">
69
+ <%= record.archived_at&.strftime("%b %d, %Y %H:%M") %> UTC
70
+ </td>
71
+ <td class="px-4 py-3 text-xs">
72
+ <% days_overdue = ((Time.current - record.archived_at) / 1.day).round %>
73
+ <span class="text-red-600 font-medium"><%= days_overdue %> days</span>
74
+ <span class="text-gray-400">(+<%= days_overdue - @retention_days %> past window)</span>
75
+ </td>
76
+ <td class="px-4 py-3 text-right">
77
+ <%= button_to fosm.purge_record_admin_data_retention_path(@slug),
78
+ params: { record_id: record.id },
79
+ method: :post,
80
+ data: {
81
+ confirm: "Permanently delete #{@name} ##{record.id}? " \
82
+ "Transition logs will be preserved. This cannot be undone."
83
+ },
84
+ class: "text-xs font-medium text-red-600 hover:text-red-800 border border-red-200 hover:border-red-400 px-2 py-1 rounded hover:bg-red-50 transition" do %>
85
+ Purge
86
+ <% end %>
87
+ </td>
88
+ </tr>
89
+ <% end %>
90
+ </tbody>
91
+ </table>
92
+ </div>
93
+
94
+ <!-- Pagination -->
95
+ <% if @total_pages > 1 %>
96
+ <div class="flex items-center justify-between text-sm text-gray-600">
97
+ <span>
98
+ Showing page <%= @page %> of <%= @total_pages %>
99
+ &middot; <%= @total %> total records eligible
100
+ </span>
101
+ <div class="flex gap-2">
102
+ <% if @page > 1 %>
103
+ <%= link_to "&larr; Previous".html_safe,
104
+ fosm.admin_data_retention_path(@slug, page: @page - 1),
105
+ class: "px-3 py-1.5 border border-gray-200 rounded text-sm hover:bg-gray-50 transition" %>
106
+ <% end %>
107
+ <% if @page < @total_pages %>
108
+ <%= link_to "Next &rarr;".html_safe,
109
+ fosm.admin_data_retention_path(@slug, page: @page + 1),
110
+ class: "px-3 py-1.5 border border-gray-200 rounded text-sm hover:bg-gray-50 transition" %>
111
+ <% end %>
112
+ </div>
113
+ </div>
114
+ <% end %>
115
+ <% end %>
116
+
117
+ <!-- Compliance reminder -->
118
+ <div class="text-xs text-gray-400 border-t border-gray-100 pt-4">
119
+ Purge operations run asynchronously via <code>Fosm::DataRetentionPurgeJob</code>.
120
+ Each record is re-verified against the retention window before deletion.
121
+ Transition audit logs are never deleted.
122
+ </div>
123
+
124
+ </div>
@@ -21,8 +21,9 @@
21
21
  <%= link_to "Transitions", fosm.admin_transitions_path, class: "block px-3 py-2 rounded text-sm text-gray-700 hover:bg-gray-100" %>
22
22
  <%= link_to "Webhooks", fosm.admin_webhooks_path, class: "block px-3 py-2 rounded text-sm text-gray-700 hover:bg-gray-100" %>
23
23
  </nav>
24
- <div class="p-3 border-t border-gray-100">
25
- <%= link_to "Settings", fosm.admin_settings_path, class: "block px-3 py-2 rounded text-sm text-gray-500 hover:bg-gray-100" %>
24
+ <div class="p-3 border-t border-gray-100 space-y-1">
25
+ <%= link_to "Settings", fosm.admin_settings_path, class: "block px-3 py-2 rounded text-sm text-gray-500 hover:bg-gray-100" %>
26
+ <%= link_to "Data Archival", fosm.admin_data_retention_index_path, class: "block px-3 py-2 rounded text-sm text-gray-500 hover:bg-gray-100" %>
26
27
  </div>
27
28
  </div>
28
29
 
data/config/routes.rb CHANGED
@@ -18,5 +18,11 @@ Fosm::Engine.routes.draw do
18
18
  end
19
19
  end
20
20
  resource :settings, only: [ :show ]
21
+ resources :data_retention, only: [ :index, :show ] do
22
+ member do
23
+ post :purge_record
24
+ post :purge_all_expired
25
+ end
26
+ end
21
27
  end
22
28
  end
@@ -1,4 +1,8 @@
1
1
  module Fosm
2
+ # Default data retention period in days (10 years).
3
+ # Override per-project via: config.data_retention_days = 2555 # 7 years
4
+ DATA_RETENTION_DEFAULT_DAYS = 3650
5
+
2
6
  class Configuration
3
7
  # The base controller class the engine's controllers will inherit from.
4
8
  # Set this to match your app's ApplicationController.
@@ -56,6 +60,15 @@ module Fosm
56
60
  # config.webhooks_enabled = false
57
61
  attr_accessor :webhooks_enabled
58
62
 
63
+ # Data retention policy in days. Records in an archival terminal state
64
+ # (state name contains "archiv") with an `archived_at` timestamp older
65
+ # than this many days are eligible for purge from the Data Archival admin.
66
+ #
67
+ # Gem default: Fosm::DATA_RETENTION_DEFAULT_DAYS (3650 = 10 years).
68
+ # Override per-project in config/initializers/fosm.rb:
69
+ # config.data_retention_days = 2555 # 7 years
70
+ attr_accessor :data_retention_days
71
+
59
72
  def initialize
60
73
  @base_controller = "ApplicationController"
61
74
  @admin_authorize = -> { true } # Override in initializer!
@@ -67,6 +80,7 @@ module Fosm
67
80
  @webhook_job_queue = :default
68
81
  @transition_log_job_queue = :fosm_audit
69
82
  @webhooks_enabled = true
83
+ @data_retention_days = Fosm::DATA_RETENTION_DEFAULT_DAYS
70
84
  end
71
85
  end
72
86
 
@@ -0,0 +1,160 @@
1
+ module Fosm
2
+ # Service for discovering and managing FOSM objects under a data retention policy.
3
+ #
4
+ # == Eligibility criteria
5
+ #
6
+ # A model is "archival-eligible" when it satisfies BOTH of the following:
7
+ # 1. It has at least one *terminal* state whose name contains "archiv"
8
+ # (case-insensitive — covers :archived, :archival, :archiviert, etc.)
9
+ # 2. Its database table has an `archived_at` datetime/timestamp column.
10
+ #
11
+ # == Retention window
12
+ #
13
+ # Driven by +Fosm.config.data_retention_days+ (default 3650 = 10 years).
14
+ # Records with a non-nil +archived_at+ older than the cutoff are
15
+ # "eligible for purge".
16
+ #
17
+ # == Audit-safety guarantee
18
+ #
19
+ # Purging a business record NEVER touches +fosm_transition_logs+. The audit
20
+ # trail is intentionally kept forever for compliance purposes. Only the
21
+ # source record (e.g. the Invoice or FaasAccount row) is deleted.
22
+ module DataRetention
23
+ class << self
24
+ # All registered FOSM model classes that meet both eligibility criteria.
25
+ #
26
+ # @return [Array<Class>]
27
+ def archival_eligible_models
28
+ Fosm::Registry.model_classes.select { |mc| archival_eligible?(mc) }
29
+ end
30
+
31
+ # Returns true when the model has an archival terminal state AND an
32
+ # `archived_at` column.
33
+ #
34
+ # @param model_class [Class]
35
+ # @return [Boolean]
36
+ def archival_eligible?(model_class)
37
+ has_archival_terminal_state?(model_class) && has_archived_at_column?(model_class)
38
+ end
39
+
40
+ # Returns true when at least one terminal state name includes "archiv".
41
+ #
42
+ # @param model_class [Class]
43
+ # @return [Boolean]
44
+ def has_archival_terminal_state?(model_class)
45
+ lifecycle = model_class.try(:fosm_lifecycle)
46
+ return false unless lifecycle
47
+ lifecycle.states.any? { |s| s.terminal? && s.name.to_s.downcase.include?("archiv") }
48
+ end
49
+
50
+ # Returns true when the model's table has an `archived_at` column.
51
+ #
52
+ # Rescues gracefully if the table doesn't exist yet (e.g. during migrations).
53
+ #
54
+ # @param model_class [Class]
55
+ # @return [Boolean]
56
+ def has_archived_at_column?(model_class)
57
+ model_class.column_names.include?("archived_at")
58
+ rescue => _e
59
+ false
60
+ end
61
+
62
+ # All archival terminal state names for the model (as strings).
63
+ #
64
+ # @param model_class [Class]
65
+ # @return [Array<String>]
66
+ def archival_states_for(model_class)
67
+ lifecycle = model_class.try(:fosm_lifecycle)
68
+ return [] unless lifecycle
69
+ lifecycle.states
70
+ .select { |s| s.terminal? && s.name.to_s.downcase.include?("archiv") }
71
+ .map { |s| s.name.to_s }
72
+ end
73
+
74
+ # The cutoff timestamp: records with `archived_at` before this moment are
75
+ # eligible for purge.
76
+ #
77
+ # @return [ActiveSupport::TimeWithZone]
78
+ def retention_cutoff_date
79
+ Fosm.config.data_retention_days.days.ago
80
+ end
81
+
82
+ # Total records currently in any archival terminal state, regardless of
83
+ # whether they have passed the retention window.
84
+ #
85
+ # @param model_class [Class]
86
+ # @return [Integer]
87
+ def total_in_archival_state(model_class)
88
+ states = archival_states_for(model_class)
89
+ return 0 if states.empty?
90
+ model_class.where(state: states).count
91
+ end
92
+
93
+ # Total records eligible for purge: in an archival state, with a non-nil
94
+ # `archived_at` older than the retention cutoff.
95
+ #
96
+ # @param model_class [Class]
97
+ # @return [Integer]
98
+ def total_eligible_for_purge(model_class)
99
+ eligible_scope(model_class).count
100
+ end
101
+
102
+ # Returns a paginated ActiveRecord relation of purge-eligible records,
103
+ # ordered oldest-first (most overdue first).
104
+ #
105
+ # Pagination is offset-based. Pages are 1-indexed.
106
+ #
107
+ # @param model_class [Class]
108
+ # @param page [Integer] 1-based page number (clamped to >= 1)
109
+ # @param per_page [Integer] max records per page
110
+ # @return [ActiveRecord::Relation]
111
+ def records_eligible_for_purge(model_class, page: 1, per_page: 50)
112
+ offset = ([page.to_i, 1].max - 1) * per_page
113
+ eligible_scope(model_class).offset(offset).limit(per_page)
114
+ end
115
+
116
+ # An unbounded scope of all purge-eligible records for use in batch jobs.
117
+ # Callers should use +find_each+ to avoid loading all rows into memory.
118
+ #
119
+ # @param model_class [Class]
120
+ # @return [ActiveRecord::Relation]
121
+ def all_eligible_for_purge(model_class)
122
+ eligible_scope(model_class)
123
+ end
124
+
125
+ # Returns true when a single record is safe to purge:
126
+ #
127
+ # - Its class has an `archived_at` column (defensive belt-and-suspenders).
128
+ # - +archived_at+ is not nil.
129
+ # - +archived_at+ is strictly older than the retention cutoff.
130
+ # - The record's current state is an archival terminal state.
131
+ #
132
+ # This is the *authoritative* pre-purge check. Always call this immediately
133
+ # before destroying, even when the controller already checked — the
134
+ # retention window or record state may have changed since the UI check.
135
+ #
136
+ # @param record [ActiveRecord::Base]
137
+ # @return [Boolean]
138
+ def safe_to_purge?(record)
139
+ return false unless record.class.column_names.include?("archived_at")
140
+ archived_at = record.archived_at
141
+ return false if archived_at.nil?
142
+ return false if archived_at >= retention_cutoff_date
143
+ archival_states_for(record.class).include?(record.state.to_s)
144
+ end
145
+
146
+ private
147
+
148
+ # Shared base scope for eligibility queries.
149
+ def eligible_scope(model_class)
150
+ states = archival_states_for(model_class)
151
+ return model_class.none if states.empty?
152
+ model_class
153
+ .where(state: states)
154
+ .where.not(archived_at: nil)
155
+ .where("archived_at < ?", retention_cutoff_date)
156
+ .order(archived_at: :asc)
157
+ end
158
+ end
159
+ end
160
+ end
data/lib/fosm/registry.rb CHANGED
@@ -23,7 +23,13 @@ module Fosm
23
23
  end
24
24
 
25
25
  def model_classes
26
- @registered.values
26
+ @registered.values.map { |klass| klass.name.constantize rescue klass }
27
+ end
28
+
29
+ def slug_for(model_class)
30
+ target_name = model_class.name
31
+ @registered.each { |slug, klass| return slug if klass.name == target_name }
32
+ nil
27
33
  end
28
34
 
29
35
  def slugs
data/lib/fosm/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Fosm
2
- VERSION = "0.2.3"
2
+ VERSION = "0.2.4"
3
3
  end
data/lib/fosm-rails.rb CHANGED
@@ -6,6 +6,7 @@ require "fosm/registry"
6
6
  require "fosm/current"
7
7
  require "fosm/transition_buffer"
8
8
  require "fosm/lifecycle"
9
+ require "fosm/data_retention"
9
10
  require "fosm/agent"
10
11
  require "fosm/engine"
11
12
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fosm-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.3
4
+ version: 0.2.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Abhishek Parolkar
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-04-18 00:00:00.000000000 Z
11
+ date: 2026-05-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -56,6 +56,7 @@ files:
56
56
  - app/controllers/fosm/admin/apps_controller.rb
57
57
  - app/controllers/fosm/admin/base_controller.rb
58
58
  - app/controllers/fosm/admin/dashboard_controller.rb
59
+ - app/controllers/fosm/admin/data_retention_controller.rb
59
60
  - app/controllers/fosm/admin/roles_controller.rb
60
61
  - app/controllers/fosm/admin/settings_controller.rb
61
62
  - app/controllers/fosm/admin/transitions_controller.rb
@@ -66,6 +67,7 @@ files:
66
67
  - app/helpers/fosm/rails/application_helper.rb
67
68
  - app/jobs/fosm/access_event_job.rb
68
69
  - app/jobs/fosm/application_job.rb
70
+ - app/jobs/fosm/data_retention_purge_job.rb
69
71
  - app/jobs/fosm/rails/application_job.rb
70
72
  - app/jobs/fosm/transition_log_job.rb
71
73
  - app/jobs/fosm/webhook_delivery_job.rb
@@ -79,6 +81,8 @@ files:
79
81
  - app/views/fosm/admin/agents/show.html.erb
80
82
  - app/views/fosm/admin/apps/show.html.erb
81
83
  - app/views/fosm/admin/dashboard/index.html.erb
84
+ - app/views/fosm/admin/data_retention/index.html.erb
85
+ - app/views/fosm/admin/data_retention/show.html.erb
82
86
  - app/views/fosm/admin/roles/index.html.erb
83
87
  - app/views/fosm/admin/roles/new.html.erb
84
88
  - app/views/fosm/admin/settings/show.html.erb
@@ -97,6 +101,7 @@ files:
97
101
  - lib/fosm/agent.rb
98
102
  - lib/fosm/configuration.rb
99
103
  - lib/fosm/current.rb
104
+ - lib/fosm/data_retention.rb
100
105
  - lib/fosm/engine.rb
101
106
  - lib/fosm/errors.rb
102
107
  - lib/fosm/lifecycle.rb