open-loam 0.1.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.
Files changed (245) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +71 -0
  3. data/LICENSE +21 -0
  4. data/README.md +177 -0
  5. data/app/jobs/loam/custom_field_reindex_job.rb +22 -0
  6. data/app/jobs/loam/event_delivery_job.rb +26 -0
  7. data/app/jobs/loam/event_redelivery_sweep_job.rb +21 -0
  8. data/app/jobs/loam/webhook_delivery_job.rb +59 -0
  9. data/app/models/loam/api_token.rb +40 -0
  10. data/app/models/loam/audit_record.rb +18 -0
  11. data/app/models/loam/auth_attempt.rb +13 -0
  12. data/app/models/loam/business_rule.rb +49 -0
  13. data/app/models/loam/business_rule_run.rb +27 -0
  14. data/app/models/loam/comment.rb +28 -0
  15. data/app/models/loam/config.rb +27 -0
  16. data/app/models/loam/custom_field_value.rb +18 -0
  17. data/app/models/loam/dashboard_widget.rb +16 -0
  18. data/app/models/loam/dictionary.rb +20 -0
  19. data/app/models/loam/dictionary_entry.rb +31 -0
  20. data/app/models/loam/event_delivery.rb +38 -0
  21. data/app/models/loam/field_definition.rb +48 -0
  22. data/app/models/loam/inbound_webhook_delivery.rb +24 -0
  23. data/app/models/loam/inbound_webhook_source.rb +55 -0
  24. data/app/models/loam/membership.rb +27 -0
  25. data/app/models/loam/mfa_credential.rb +114 -0
  26. data/app/models/loam/notification.rb +36 -0
  27. data/app/models/loam/pending_action.rb +208 -0
  28. data/app/models/loam/perspective.rb +104 -0
  29. data/app/models/loam/progress_job.rb +103 -0
  30. data/app/models/loam/record_lock.rb +19 -0
  31. data/app/models/loam/scheduled_job.rb +42 -0
  32. data/app/models/loam/search_token.rb +9 -0
  33. data/app/models/loam/sso_identity.rb +13 -0
  34. data/app/models/loam/sso_provider.rb +35 -0
  35. data/app/models/loam/tenant.rb +20 -0
  36. data/app/models/loam/translation.rb +17 -0
  37. data/app/models/loam/webhook_endpoint.rb +26 -0
  38. data/app/views/loam/attachments/_attachments.html.erb +16 -0
  39. data/app/views/loam/comments/_comments.html.erb +24 -0
  40. data/app/views/loam/custom_fields/_fields.html.erb +35 -0
  41. data/lib/generators/loam/entity/entity_generator.rb +160 -0
  42. data/lib/generators/loam/entity/templates/api_controller.rb +67 -0
  43. data/lib/generators/loam/entity/templates/controller.rb +181 -0
  44. data/lib/generators/loam/entity/templates/entity_test.rb +107 -0
  45. data/lib/generators/loam/entity/templates/migration.rb +26 -0
  46. data/lib/generators/loam/entity/templates/model.rb +27 -0
  47. data/lib/generators/loam/entity/templates/policy.rb +10 -0
  48. data/lib/generators/loam/entity/templates/views/_form.html.erb +63 -0
  49. data/lib/generators/loam/entity/templates/views/deleted.html.erb +41 -0
  50. data/lib/generators/loam/entity/templates/views/edit.html.erb +3 -0
  51. data/lib/generators/loam/entity/templates/views/index.html.erb +90 -0
  52. data/lib/generators/loam/entity/templates/views/new.html.erb +3 -0
  53. data/lib/generators/loam/entity/templates/views/show.html.erb +36 -0
  54. data/lib/generators/loam/install/install_generator.rb +288 -0
  55. data/lib/generators/loam/install/templates/AGENTS.md +341 -0
  56. data/lib/generators/loam/install/templates/admin/api_docs_controller.rb +17 -0
  57. data/lib/generators/loam/install/templates/admin/api_docs_index.html.erb +34 -0
  58. data/lib/generators/loam/install/templates/admin/api_tokens_controller.rb +30 -0
  59. data/lib/generators/loam/install/templates/admin/api_tokens_index.html.erb +39 -0
  60. data/lib/generators/loam/install/templates/admin/base_controller.rb +224 -0
  61. data/lib/generators/loam/install/templates/admin/business_rules_controller.rb +64 -0
  62. data/lib/generators/loam/install/templates/admin/business_rules_edit.html.erb +3 -0
  63. data/lib/generators/loam/install/templates/admin/business_rules_form.html.erb +32 -0
  64. data/lib/generators/loam/install/templates/admin/business_rules_index.html.erb +45 -0
  65. data/lib/generators/loam/install/templates/admin/business_rules_new.html.erb +3 -0
  66. data/lib/generators/loam/install/templates/admin/comments_controller.rb +39 -0
  67. data/lib/generators/loam/install/templates/admin/configs_controller.rb +46 -0
  68. data/lib/generators/loam/install/templates/admin/configs_edit.html.erb +22 -0
  69. data/lib/generators/loam/install/templates/admin/configs_index.html.erb +30 -0
  70. data/lib/generators/loam/install/templates/admin/dashboard_controller.rb +10 -0
  71. data/lib/generators/loam/install/templates/admin/dashboard_index.html.erb +26 -0
  72. data/lib/generators/loam/install/templates/admin/dashboard_widgets_controller.rb +23 -0
  73. data/lib/generators/loam/install/templates/admin/dashboard_widgets_index.html.erb +24 -0
  74. data/lib/generators/loam/install/templates/admin/dictionaries_controller.rb +55 -0
  75. data/lib/generators/loam/install/templates/admin/dictionaries_edit.html.erb +45 -0
  76. data/lib/generators/loam/install/templates/admin/dictionaries_form.html.erb +10 -0
  77. data/lib/generators/loam/install/templates/admin/dictionaries_index.html.erb +21 -0
  78. data/lib/generators/loam/install/templates/admin/dictionaries_new.html.erb +5 -0
  79. data/lib/generators/loam/install/templates/admin/dictionary_entries_controller.rb +47 -0
  80. data/lib/generators/loam/install/templates/admin/event_deliveries_controller.rb +23 -0
  81. data/lib/generators/loam/install/templates/admin/event_deliveries_index.html.erb +49 -0
  82. data/lib/generators/loam/install/templates/admin/events_controller.rb +42 -0
  83. data/lib/generators/loam/install/templates/admin/features_controller.rb +35 -0
  84. data/lib/generators/loam/install/templates/admin/features_index.html.erb +37 -0
  85. data/lib/generators/loam/install/templates/admin/field_definitions_controller.rb +52 -0
  86. data/lib/generators/loam/install/templates/admin/field_definitions_index.html.erb +30 -0
  87. data/lib/generators/loam/install/templates/admin/field_definitions_new.html.erb +39 -0
  88. data/lib/generators/loam/install/templates/admin/history_controller.rb +38 -0
  89. data/lib/generators/loam/install/templates/admin/history_index.html.erb +35 -0
  90. data/lib/generators/loam/install/templates/admin/imports_controller.rb +75 -0
  91. data/lib/generators/loam/install/templates/admin/imports_new.html.erb +10 -0
  92. data/lib/generators/loam/install/templates/admin/imports_preview.html.erb +32 -0
  93. data/lib/generators/loam/install/templates/admin/imports_summary.html.erb +32 -0
  94. data/lib/generators/loam/install/templates/admin/inbound_webhook_sources_controller.rb +61 -0
  95. data/lib/generators/loam/install/templates/admin/inbound_webhook_sources_index.html.erb +49 -0
  96. data/lib/generators/loam/install/templates/admin/inbound_webhook_sources_new.html.erb +32 -0
  97. data/lib/generators/loam/install/templates/admin/layout.html.erb +76 -0
  98. data/lib/generators/loam/install/templates/admin/mfa_activated.html.erb +12 -0
  99. data/lib/generators/loam/install/templates/admin/mfa_controller.rb +61 -0
  100. data/lib/generators/loam/install/templates/admin/mfa_new.html.erb +23 -0
  101. data/lib/generators/loam/install/templates/admin/mfa_show.html.erb +19 -0
  102. data/lib/generators/loam/install/templates/admin/notifications_controller.rb +23 -0
  103. data/lib/generators/loam/install/templates/admin/notifications_index.html.erb +32 -0
  104. data/lib/generators/loam/install/templates/admin/overrides_controller.rb +14 -0
  105. data/lib/generators/loam/install/templates/admin/overrides_index.html.erb +29 -0
  106. data/lib/generators/loam/install/templates/admin/pagination.rb +23 -0
  107. data/lib/generators/loam/install/templates/admin/pending_actions_controller.rb +38 -0
  108. data/lib/generators/loam/install/templates/admin/pending_actions_index.html.erb +40 -0
  109. data/lib/generators/loam/install/templates/admin/perspectives_controller.rb +98 -0
  110. data/lib/generators/loam/install/templates/admin/perspectives_index.html.erb +38 -0
  111. data/lib/generators/loam/install/templates/admin/progress_jobs_controller.rb +19 -0
  112. data/lib/generators/loam/install/templates/admin/progress_jobs_index.html.erb +31 -0
  113. data/lib/generators/loam/install/templates/admin/record_locks_controller.rb +17 -0
  114. data/lib/generators/loam/install/templates/admin/scheduled_jobs_controller.rb +69 -0
  115. data/lib/generators/loam/install/templates/admin/scheduled_jobs_edit.html.erb +5 -0
  116. data/lib/generators/loam/install/templates/admin/scheduled_jobs_form.html.erb +16 -0
  117. data/lib/generators/loam/install/templates/admin/scheduled_jobs_index.html.erb +28 -0
  118. data/lib/generators/loam/install/templates/admin/scheduled_jobs_new.html.erb +5 -0
  119. data/lib/generators/loam/install/templates/admin/search_controller.rb +49 -0
  120. data/lib/generators/loam/install/templates/admin/search_index.html.erb +23 -0
  121. data/lib/generators/loam/install/templates/admin/sessions_controller.rb +248 -0
  122. data/lib/generators/loam/install/templates/admin/sessions_mfa_challenge.html.erb +18 -0
  123. data/lib/generators/loam/install/templates/admin/sessions_new.html.erb +45 -0
  124. data/lib/generators/loam/install/templates/admin/sso_providers_controller.rb +65 -0
  125. data/lib/generators/loam/install/templates/admin/sso_providers_edit.html.erb +3 -0
  126. data/lib/generators/loam/install/templates/admin/sso_providers_form.html.erb +32 -0
  127. data/lib/generators/loam/install/templates/admin/sso_providers_index.html.erb +26 -0
  128. data/lib/generators/loam/install/templates/admin/sso_providers_new.html.erb +3 -0
  129. data/lib/generators/loam/install/templates/admin/sudo_controller.rb +45 -0
  130. data/lib/generators/loam/install/templates/admin/sudo_new.html.erb +24 -0
  131. data/lib/generators/loam/install/templates/admin/translations_controller.rb +37 -0
  132. data/lib/generators/loam/install/templates/admin/translations_index.html.erb +21 -0
  133. data/lib/generators/loam/install/templates/admin/webhook_endpoints_controller.rb +38 -0
  134. data/lib/generators/loam/install/templates/admin/webhook_endpoints_index.html.erb +32 -0
  135. data/lib/generators/loam/install/templates/admin/webhook_endpoints_new.html.erb +28 -0
  136. data/lib/generators/loam/install/templates/admin.css +29 -0
  137. data/lib/generators/loam/install/templates/api_base_controller.rb +90 -0
  138. data/lib/generators/loam/install/templates/guardrails_test.rb +79 -0
  139. data/lib/generators/loam/install/templates/import_job.rb +25 -0
  140. data/lib/generators/loam/install/templates/inbound_webhooks_controller.rb +17 -0
  141. data/lib/generators/loam/install/templates/initializer.rb +218 -0
  142. data/lib/generators/loam/install/templates/migrations/create_loam_api_tokens.rb +15 -0
  143. data/lib/generators/loam/install/templates/migrations/create_loam_audit_records.rb +14 -0
  144. data/lib/generators/loam/install/templates/migrations/create_loam_auth_attempts.rb +11 -0
  145. data/lib/generators/loam/install/templates/migrations/create_loam_business_rules.rb +29 -0
  146. data/lib/generators/loam/install/templates/migrations/create_loam_comments.rb +13 -0
  147. data/lib/generators/loam/install/templates/migrations/create_loam_configs.rb +21 -0
  148. data/lib/generators/loam/install/templates/migrations/create_loam_custom_field_values.rb +19 -0
  149. data/lib/generators/loam/install/templates/migrations/create_loam_dashboard_widgets.rb +12 -0
  150. data/lib/generators/loam/install/templates/migrations/create_loam_dictionaries.rb +11 -0
  151. data/lib/generators/loam/install/templates/migrations/create_loam_dictionary_entries.rb +18 -0
  152. data/lib/generators/loam/install/templates/migrations/create_loam_event_deliveries.rb +17 -0
  153. data/lib/generators/loam/install/templates/migrations/create_loam_field_definitions.rb +15 -0
  154. data/lib/generators/loam/install/templates/migrations/create_loam_inbound_webhooks.rb +31 -0
  155. data/lib/generators/loam/install/templates/migrations/create_loam_memberships.rb +11 -0
  156. data/lib/generators/loam/install/templates/migrations/create_loam_mfa_credentials.rb +13 -0
  157. data/lib/generators/loam/install/templates/migrations/create_loam_notifications.rb +15 -0
  158. data/lib/generators/loam/install/templates/migrations/create_loam_pending_actions.rb +26 -0
  159. data/lib/generators/loam/install/templates/migrations/create_loam_perspectives.rb +17 -0
  160. data/lib/generators/loam/install/templates/migrations/create_loam_progress_jobs.rb +20 -0
  161. data/lib/generators/loam/install/templates/migrations/create_loam_record_locks.rb +16 -0
  162. data/lib/generators/loam/install/templates/migrations/create_loam_scheduled_jobs.rb +20 -0
  163. data/lib/generators/loam/install/templates/migrations/create_loam_search_tokens.rb +18 -0
  164. data/lib/generators/loam/install/templates/migrations/create_loam_sso_providers.rb +34 -0
  165. data/lib/generators/loam/install/templates/migrations/create_loam_tenants.rb +9 -0
  166. data/lib/generators/loam/install/templates/migrations/create_loam_translations.rb +15 -0
  167. data/lib/generators/loam/install/templates/migrations/create_loam_webhook_endpoints.rb +13 -0
  168. data/lib/generators/loam/install/templates/migrations/create_users.rb +13 -0
  169. data/lib/generators/loam/install/templates/user.rb +21 -0
  170. data/lib/generators/loam/primary_key_options.rb +101 -0
  171. data/lib/loam/attachable.rb +27 -0
  172. data/lib/loam/auditable.rb +67 -0
  173. data/lib/loam/auth_throttle.rb +83 -0
  174. data/lib/loam/base32.rb +23 -0
  175. data/lib/loam/bulk.rb +59 -0
  176. data/lib/loam/business_rules/actions.rb +92 -0
  177. data/lib/loam/business_rules/condition.rb +102 -0
  178. data/lib/loam/business_rules.rb +123 -0
  179. data/lib/loam/commentable.rb +22 -0
  180. data/lib/loam/configs.rb +115 -0
  181. data/lib/loam/cron.rb +98 -0
  182. data/lib/loam/csv.rb +18 -0
  183. data/lib/loam/current.rb +21 -0
  184. data/lib/loam/custom_field_index.rb +262 -0
  185. data/lib/loam/custom_fields.rb +114 -0
  186. data/lib/loam/dashboard.rb +17 -0
  187. data/lib/loam/dictionaries.rb +55 -0
  188. data/lib/loam/durable_events.rb +174 -0
  189. data/lib/loam/encryptable.rb +145 -0
  190. data/lib/loam/encryption/cipher.rb +75 -0
  191. data/lib/loam/encryption/key_provider.rb +43 -0
  192. data/lib/loam/encryption.rb +125 -0
  193. data/lib/loam/engine.rb +57 -0
  194. data/lib/loam/enrichers.rb +105 -0
  195. data/lib/loam/errors.rb +49 -0
  196. data/lib/loam/eval.rb +47 -0
  197. data/lib/loam/event_stream.rb +80 -0
  198. data/lib/loam/eventful.rb +45 -0
  199. data/lib/loam/events.rb +51 -0
  200. data/lib/loam/export.rb +74 -0
  201. data/lib/loam/features.rb +75 -0
  202. data/lib/loam/generated_key.rb +37 -0
  203. data/lib/loam/import.rb +168 -0
  204. data/lib/loam/inbound_webhooks.rb +125 -0
  205. data/lib/loam/lifecycle.rb +180 -0
  206. data/lib/loam/locales/loam.en.yml +80 -0
  207. data/lib/loam/mcp/server.rb +60 -0
  208. data/lib/loam/mcp.rb +304 -0
  209. data/lib/loam/notifications.rb +36 -0
  210. data/lib/loam/open_api.rb +194 -0
  211. data/lib/loam/overrides.rb +110 -0
  212. data/lib/loam/pending_actions.rb +60 -0
  213. data/lib/loam/permissions.rb +79 -0
  214. data/lib/loam/perspectives.rb +62 -0
  215. data/lib/loam/policy.rb +106 -0
  216. data/lib/loam/progress.rb +31 -0
  217. data/lib/loam/record_locks.rb +89 -0
  218. data/lib/loam/scheduler.rb +153 -0
  219. data/lib/loam/search/driver.rb +31 -0
  220. data/lib/loam/search/like_driver.rb +29 -0
  221. data/lib/loam/search/token_driver.rb +88 -0
  222. data/lib/loam/search.rb +27 -0
  223. data/lib/loam/searchable.rb +90 -0
  224. data/lib/loam/soft_deletable.rb +72 -0
  225. data/lib/loam/sso/claims.rb +10 -0
  226. data/lib/loam/sso/fake_provider.rb +51 -0
  227. data/lib/loam/sso/http_client.rb +42 -0
  228. data/lib/loam/sso/oidc_provider.rb +64 -0
  229. data/lib/loam/sso.rb +129 -0
  230. data/lib/loam/telemetry.rb +43 -0
  231. data/lib/loam/tenant_record.rb +36 -0
  232. data/lib/loam/test_helpers.rb +15 -0
  233. data/lib/loam/totp.rb +63 -0
  234. data/lib/loam/translatable.rb +88 -0
  235. data/lib/loam/undo.rb +126 -0
  236. data/lib/loam/version.rb +3 -0
  237. data/lib/loam/webhooks.rb +34 -0
  238. data/lib/loam/widgets.rb +77 -0
  239. data/lib/loam/workflow.rb +256 -0
  240. data/lib/loam.rb +122 -0
  241. data/lib/open-loam.rb +5 -0
  242. data/lib/tasks/loam.rake +171 -0
  243. data/lib/tasks/loam_eval.rake +32 -0
  244. data/lib/tasks/loam_mcp.rake +20 -0
  245. metadata +332 -0
@@ -0,0 +1,77 @@
1
+ module Loam
2
+ # A registry of dashboard widgets — small, module-provided tiles (a metric, a
3
+ # short list) shown on the admin home. A widget is a DATA PROVIDER plus a
4
+ # title and an optional role filter; it never renders arbitrary code:
5
+ #
6
+ # Loam::Widgets.register(key: "open_rentals", title: "Open rentals", roles: %w[manager]) do |actor|
7
+ # { kind: "count", value: Rental.where(status: "open").count } # tenant-scoped query
8
+ # end
9
+ #
10
+ # The provider returns a small data hash (`{ kind: "count", value: }` or
11
+ # `{ kind: "list", items: [...] }`); the dashboard renders it generically.
12
+ # Widgets run tenant-scoped (query through tenant-scoped models), the `roles:`
13
+ # filter is enforced server-side (a hidden widget's data is NOT computed), and
14
+ # a raising provider is isolated into an error tile — the dashboard never breaks.
15
+ module Widgets
16
+ Widget = Struct.new(:key, :title, :roles, :provider, keyword_init: true) do
17
+ def visible_to?(role)
18
+ roles.nil? || Array(roles).map(&:to_s).include?(role.to_s)
19
+ end
20
+ end
21
+
22
+ class << self
23
+ def register(key:, title:, roles: nil, &block)
24
+ registry[key.to_s] = Widget.new(key: key.to_s, title: title, roles: roles, provider: block)
25
+ key.to_s
26
+ end
27
+
28
+ def registered = registry.values
29
+ def keys = registry.keys
30
+ def find(key) = registry[key.to_s]
31
+
32
+ def reset!
33
+ @registry = {}
34
+ end
35
+
36
+ # Resolve a widget for (actor, role): nil when it doesn't exist or the role
37
+ # can't see it (so its data is never computed), otherwise
38
+ # { key, title, data } — or { key, title, error: } when the provider raises.
39
+ def resolve(key, actor:, role:)
40
+ return nil if Loam::Overrides.disabled?(:widgets, key) # customization without forking
41
+
42
+ widget = find(key)
43
+ return nil unless widget&.visible_to?(role)
44
+
45
+ provider = Loam::Overrides.replacement(:widgets, key) || widget.provider
46
+ { key: widget.key, title: widget.title, data: provider.call(actor) }
47
+ rescue StandardError => error
48
+ { key: widget&.key, title: widget&.title, error: error.message }
49
+ end
50
+
51
+ # The widgets every Loam app starts with — registered at boot from the
52
+ # engine, so a fresh install has a useful default dashboard.
53
+ def register_builtins!
54
+ register(key: "audit_recent", title: "Recent activity") do |_actor|
55
+ items = Loam::AuditRecord.order(created_at: :desc).limit(8)
56
+ .map { |a| "#{a.action} #{a.auditable_type} ##{a.auditable_id}" }
57
+ { kind: "list", items: items }
58
+ end
59
+ register(key: "notifications_unread", title: "Unread notifications") do |actor|
60
+ { kind: "count", value: Loam::Notification.unread.where(user_id: actor&.id).count }
61
+ end
62
+ register(key: "pending_approvals", title: "Pending approvals", roles: %w[manager]) do |_actor|
63
+ { kind: "count", value: Loam::PendingAction.pending.count }
64
+ end
65
+ register(key: "open_progress", title: "Running tasks") do |_actor|
66
+ { kind: "count", value: Loam::ProgressJob.where(status: "running").count }
67
+ end
68
+ end
69
+
70
+ private
71
+
72
+ def registry
73
+ @registry ||= {}
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,256 @@
1
+ module Loam
2
+ # Declarative state machine for a tenant-scoped entity: the states a record
3
+ # can be in, the legal moves between them, and who may make each move.
4
+ #
5
+ # class PurchaseOrder < Loam::TenantRecord
6
+ # include Loam::Workflow
7
+ #
8
+ # workflow :status, initial: "draft" do
9
+ # state "draft"
10
+ # state "pending_approval"
11
+ # state "approved"
12
+ #
13
+ # transition :submit, from: "draft", to: "pending_approval"
14
+ # transition :approve, from: "pending_approval", to: "approved", roles: [:manager]
15
+ # end
16
+ # end
17
+ #
18
+ # order.submit! # draft -> pending_approval, publishes
19
+ # # "<domain>.purchase_order.submit"
20
+ # order.workflow_transitions_available # => [:approve] for a manager, [] for anyone else
21
+ # PurchaseOrder.loam_workflow # the frozen definition, for agents and admin UI
22
+ #
23
+ # What this buys over hand-rolled `if status == "draft"`: an illegal move
24
+ # raises instead of silently writing a bad value, the roles that may move a
25
+ # record are declared in one readable place instead of scattered across
26
+ # controllers, and the whole machine is introspectable.
27
+ #
28
+ # The state change itself is an ordinary attribute write, so Loam::Auditable
29
+ # records it like any other change — a workflow adds no second audit trail.
30
+ module Workflow
31
+ extend ActiveSupport::Concern
32
+
33
+ # One declared move. `from` is a list because several states may share a
34
+ # move (e.g. cancel from draft OR pending_approval); `roles` empty means
35
+ # "not role-gated here" — the policy layer still gates the controller.
36
+ Transition = Struct.new(:name, :from, :to, :roles, keyword_init: true)
37
+
38
+ # The whole machine, frozen at class-definition time.
39
+ Definition = Struct.new(:column, :initial, :states, :transitions, keyword_init: true) do
40
+ def transitions_from(state)
41
+ transitions.each_value.select { |transition| transition.from.include?(state.to_s) }
42
+ end
43
+ end
44
+
45
+ # Collects the DSL block and validates the machine as a whole before
46
+ # freezing it. Validation runs at the end, so `state` and `transition` may
47
+ # appear in any order inside the block.
48
+ class Builder
49
+ def initialize(column, initial)
50
+ @column = column.to_s
51
+ @initial = initial&.to_s
52
+ @states = []
53
+ @transitions = {}
54
+ end
55
+
56
+ def state(name)
57
+ @states << name.to_s
58
+ end
59
+
60
+ def transition(name, from:, to:, roles: [])
61
+ @transitions[name.to_sym] = Transition.new(
62
+ name: name.to_sym,
63
+ from: Array(from).map(&:to_s).freeze,
64
+ to: to.to_s,
65
+ roles: Array(roles).map(&:to_sym).freeze
66
+ ).freeze
67
+ end
68
+
69
+ def build(&block)
70
+ instance_eval(&block)
71
+ validate!
72
+
73
+ Definition.new(
74
+ column: @column,
75
+ initial: @initial || @states.first,
76
+ states: @states.freeze,
77
+ transitions: @transitions.freeze
78
+ ).freeze
79
+ end
80
+
81
+ private
82
+
83
+ # A typo in a state name is a bug that would otherwise surface much
84
+ # later, as a transition that can never fire. Fail at class load.
85
+ def validate!
86
+ raise Error, "workflow #{@column} declares no states" if @states.empty?
87
+
88
+ undeclared = @transitions.each_value.flat_map { |t| t.from + [t.to] }.uniq - @states
89
+ if undeclared.any?
90
+ raise Error, "workflow #{@column} moves to/from undeclared states: #{undeclared.join(', ')} " \
91
+ "(declare each one with `state \"name\"`)"
92
+ end
93
+
94
+ return if @initial.nil? || @states.include?(@initial)
95
+
96
+ raise Error, "workflow #{@column} initial state #{@initial.inspect} is not a declared state"
97
+ end
98
+ end
99
+
100
+ included do
101
+ # nil until `workflow` is called; inherited by subclasses.
102
+ class_attribute :loam_workflow, instance_writer: false, default: nil
103
+
104
+ # THE gate: the workflow column may only change through a transition
105
+ # (loam_perform_transition!), which enforces from-state and role. A direct
106
+ # write — the edit form, Bulk.set_field, an import, a business-rule
107
+ # set_field — would otherwise let a member self-"approve", skipping the
108
+ # transition's roles:. Closing it here closes ALL paths at once. Creation
109
+ # (the initial state) is exempt (`on: :update`).
110
+ validate :loam_workflow_column_only_via_transition, on: :update
111
+ end
112
+
113
+ class_methods do
114
+ # The DSL. `initial:` defaults to the first declared state.
115
+ def workflow(column, initial: nil, &block)
116
+ definition = Builder.new(column, initial).build(&block)
117
+ self.loam_workflow = definition
118
+
119
+ before_validation on: :create do
120
+ self[definition.column] = definition.initial if self[definition.column].blank?
121
+ end
122
+
123
+ # A workflow column may only ever hold a declared state, whoever writes
124
+ # it — a transition, a form, a console.
125
+ validates definition.column,
126
+ inclusion: { in: definition.states, message: "is not one of: #{definition.states.join(', ')}" }
127
+
128
+ include loam_workflow_module(definition)
129
+ end
130
+
131
+ # Transition and predicate methods live in their own module rather than
132
+ # on the class, so an app can override `submit!` and still call `super`.
133
+ def loam_workflow_module(definition)
134
+ model = self
135
+
136
+ Module.new do
137
+ definition.transitions.each_value do |transition|
138
+ define_method("#{transition.name}!") { loam_perform_transition!(transition) }
139
+ end
140
+
141
+ definition.states.each do |state|
142
+ predicate = "#{state}?"
143
+ next unless model.loam_workflow_predicate_free?(predicate)
144
+
145
+ # A state predicate must never shadow a real column: DamageReport
146
+ # has both a workflow state "approved" and an `approved` boolean
147
+ # column, and `record.approved?` has to stay the column's. The
148
+ # column check happens at CALL time, not class-load time — at load
149
+ # the schema may not be readable yet (fresh CI database, db:create),
150
+ # and a load-time decision made Loam behave differently on CI than
151
+ # on a warmed-up dev machine.
152
+ define_method(predicate) do
153
+ if self.class.columns_hash.key?(state)
154
+ query_attribute(state)
155
+ else
156
+ self[definition.column].to_s == state
157
+ end
158
+ end
159
+ end
160
+ end
161
+ end
162
+
163
+ # Explicitly defined methods still win — only truly free names get a
164
+ # workflow predicate. Column collisions are handled inside the predicate
165
+ # itself (see loam_workflow_module), where the schema is reliably known.
166
+ def loam_workflow_predicate_free?(predicate)
167
+ !method_defined?(predicate) && !private_method_defined?(predicate)
168
+ end
169
+ end
170
+
171
+ # Transition names that are legal right now: from this record's state, for
172
+ # the actor in Loam::Current. Never raises — an admin screen asks this to
173
+ # decide which buttons to render.
174
+ def workflow_transitions_available
175
+ role = loam_workflow_role
176
+
177
+ self.class.loam_workflow.transitions_from(loam_workflow_state)
178
+ .select { |transition| transition.roles.empty? || transition.roles.include?(role) }
179
+ .map(&:name)
180
+ end
181
+
182
+ def loam_workflow_state
183
+ self[self.class.loam_workflow.column].to_s
184
+ end
185
+
186
+ private
187
+
188
+ def loam_workflow_column_only_via_transition
189
+ return unless self.class.loam_workflow
190
+
191
+ column = self.class.loam_workflow.column
192
+ return unless attribute_changed?(column)
193
+ return if @loam_in_transition
194
+
195
+ errors.add(column, "can only change through a workflow transition, not a direct write")
196
+ end
197
+
198
+ def loam_perform_transition!(transition)
199
+ from = loam_workflow_state
200
+
201
+ unless transition.from.include?(from)
202
+ raise InvalidTransitionError,
203
+ "#{self.class.name}##{transition.name}! moves #{transition.from.join('/')} -> #{transition.to}, " \
204
+ "but this record is #{from.inspect}"
205
+ end
206
+
207
+ loam_authorize_transition!(transition)
208
+
209
+ self[self.class.loam_workflow.column] = transition.to
210
+ begin
211
+ @loam_in_transition = true # tells the guard THIS column change is blessed
212
+ save!
213
+ ensure
214
+ @loam_in_transition = false
215
+ end
216
+
217
+ Loam::Events.publish(
218
+ "#{loam_workflow_event_domain}.#{model_name.param_key}.#{transition.name}",
219
+ id: id, from: from, to: transition.to
220
+ )
221
+
222
+ self
223
+ end
224
+
225
+ def loam_authorize_transition!(transition)
226
+ return if transition.roles.empty?
227
+
228
+ allowed = transition.roles.join(", ")
229
+ unless Loam::Current.actor
230
+ raise NotAuthorizedError,
231
+ "#{self.class.name}##{transition.name}! is restricted to #{allowed}, but there is no actor in " \
232
+ "Loam::Current — wrap the call in Loam.as_tenant(tenant, actor: user) { ... }"
233
+ end
234
+
235
+ return if transition.roles.include?(loam_workflow_role)
236
+
237
+ raise NotAuthorizedError,
238
+ "#{self.class.name}##{transition.name}! is restricted to #{allowed}; " \
239
+ "this actor's role is #{loam_workflow_role.inspect}"
240
+ end
241
+
242
+ # Role resolution deliberately goes through Loam::Policy: "your role" means
243
+ # exactly one thing — your membership role in the current tenant — here, in
244
+ # policies, and in the admin.
245
+ def loam_workflow_role
246
+ actor = Loam::Current.actor
247
+ actor && Loam::Policy.new(actor, self).role
248
+ end
249
+
250
+ # Transitions publish into the model's own event domain when it has one
251
+ # (Loam::Eventful), so workflow events sit beside the lifecycle events.
252
+ def loam_workflow_event_domain
253
+ self.class.respond_to?(:loam_event_domain) ? self.class.loam_event_domain : "app"
254
+ end
255
+ end
256
+ end
data/lib/loam.rb ADDED
@@ -0,0 +1,122 @@
1
+ require "loam/version"
2
+ require "loam/errors"
3
+ require "loam/current"
4
+ require "loam/events"
5
+ require "loam/policy"
6
+ require "loam/lifecycle"
7
+ require "loam/configs"
8
+ require "loam/features"
9
+ require "loam/auth_throttle"
10
+ require "loam/dictionaries"
11
+ require "loam/custom_field_index"
12
+ require "loam/undo"
13
+ require "loam/permissions"
14
+ require "loam/telemetry"
15
+ require "loam/eval"
16
+ require "loam/mcp"
17
+ require "loam/mcp/server"
18
+ require "loam/progress"
19
+ require "loam/cron"
20
+ require "loam/scheduler"
21
+ require "loam/csv"
22
+ require "loam/export"
23
+ require "loam/import"
24
+ require "loam/bulk"
25
+ require "loam/widgets"
26
+ require "loam/dashboard"
27
+ require "loam/overrides"
28
+ require "loam/open_api"
29
+ require "loam/encryption"
30
+ require "loam/base32"
31
+ require "loam/totp"
32
+ require "loam/pending_actions"
33
+ require "loam/perspectives"
34
+ require "loam/record_locks"
35
+ require "loam/event_stream"
36
+ require "loam/enrichers"
37
+ require "loam/search"
38
+ require "loam/search/driver"
39
+ require "loam/search/like_driver"
40
+ require "loam/search/token_driver"
41
+ require "loam/notifications"
42
+ require "loam/sso"
43
+ require "loam/business_rules"
44
+ require "loam/webhooks"
45
+ require "loam/inbound_webhooks"
46
+ require "loam/durable_events"
47
+ require "loam/engine" if defined?(Rails::Engine)
48
+
49
+ module Loam
50
+ # The current tenant, or a loud failure. This is THE guardrail: tenant-scoped
51
+ # code paths call this, so a missing tenant context can never silently widen
52
+ # a query to all tenants.
53
+ def self.tenant!
54
+ Current.tenant or raise MissingTenantError
55
+ end
56
+
57
+ def self.tenant
58
+ Current.tenant
59
+ end
60
+
61
+ def self.actor
62
+ Current.actor
63
+ end
64
+
65
+ # Feature-string permission check for the current actor's role (see
66
+ # Loam::Permissions) — deny-by-default, wildcard-aware. `role:` overrides the
67
+ # actor's role. Returns false with no actor/role.
68
+ def self.can?(permission, role: nil)
69
+ role ||= Current.actor && Loam::Membership.find_by(user_id: Current.actor.id)&.role
70
+ Loam::Permissions.allow?(role, permission)
71
+ end
72
+
73
+ # The approval-gate seam. When a caller runs under :confirm (an MCP tool acting
74
+ # for an AI agent), a write should be STAGED for human approval via
75
+ # Loam::PendingActions.stage instead of committed. Loam does not intercept
76
+ # Active Record globally — this is the documented hook a write path checks.
77
+ def self.mutation_mode
78
+ Current.mutation_mode || :direct
79
+ end
80
+
81
+ def self.require_confirmation?
82
+ mutation_mode == :confirm
83
+ end
84
+
85
+ # Run a block with writes gated for approval. The one blessed way to enter
86
+ # confirm-mode; restores the previous mode afterwards.
87
+ def self.with_confirmation
88
+ previous = Current.mutation_mode
89
+ Current.mutation_mode = :confirm
90
+ yield
91
+ ensure
92
+ Current.mutation_mode = previous
93
+ end
94
+
95
+ # Run a block inside a tenant (and optional actor) context, restoring the
96
+ # previous context afterwards. The one blessed way to switch tenants.
97
+ def self.as_tenant(tenant, actor: nil)
98
+ previous_tenant = Current.tenant
99
+ previous_actor = Current.actor
100
+ Current.tenant = tenant
101
+ Current.actor = actor if actor
102
+ yield
103
+ ensure
104
+ Current.tenant = previous_tenant
105
+ Current.actor = previous_actor
106
+ end
107
+ end
108
+
109
+ ActiveSupport.on_load(:active_record) do
110
+ require "loam/generated_key"
111
+ require "loam/tenant_record"
112
+ require "loam/auditable"
113
+ require "loam/soft_deletable"
114
+ require "loam/eventful"
115
+ require "loam/custom_fields"
116
+ require "loam/workflow"
117
+ require "loam/searchable"
118
+ require "loam/encryptable"
119
+ require "loam/translatable"
120
+ require "loam/commentable"
121
+ require "loam/attachable"
122
+ end
data/lib/open-loam.rb ADDED
@@ -0,0 +1,5 @@
1
+ # Bundler auto-requires the gem's name ("open-loam") on load. The library's
2
+ # entry point and whole namespace are `loam` / `Loam::` — the gem is only
3
+ # *distributed* as open-loam (the plain `loam` name is taken on RubyGems). This
4
+ # shim bridges the two so `gem "open-loam"` works with the default require.
5
+ require_relative "loam"
@@ -0,0 +1,171 @@
1
+ namespace :loam do
2
+ desc "Re-run every Loam.on_tenant_created callback for every existing tenant (idempotent)"
3
+ task sync: :environment do
4
+ synced = Loam.sync_tenants!
5
+ puts "loam:sync — ran #{Loam.tenant_created_callbacks.size} tenant callback(s) across #{synced} tenant(s)."
6
+ end
7
+
8
+ namespace :openapi do
9
+ # Write the OpenAPI 3.1 document + a Markdown rendering to disk, for CI or
10
+ # publishing. Introspection only — no server, no network.
11
+ #
12
+ # bin/rails loam:openapi:export
13
+ desc "Export the API's OpenAPI JSON + Markdown to doc/"
14
+ task export: :environment do
15
+ require "json"
16
+ dir = ENV["DIR"].presence || "doc"
17
+ FileUtils.mkdir_p(dir)
18
+ File.write(File.join(dir, "openapi.json"), JSON.pretty_generate(Loam::OpenApi.document))
19
+ File.write(File.join(dir, "openapi.md"), Loam::OpenApi.markdown)
20
+ puts "loam:openapi:export — wrote #{dir}/openapi.json and #{dir}/openapi.md."
21
+ end
22
+ end
23
+
24
+ namespace :scheduler do
25
+ # Fire every due recurring job once. Wire to system cron, every minute:
26
+ # * * * * * cd /app && bin/rails loam:scheduler:tick
27
+ # The claim is atomic, so running this from several hosts never double-fires
28
+ # a job (Postgres SKIP LOCKED; SQLite serializes the single-process claim).
29
+ desc "Enqueue every due Loam::ScheduledJob (run from cron)"
30
+ task tick: :environment do
31
+ fired = Loam::Scheduler.tick
32
+ puts "loam:scheduler:tick — fired #{fired} scheduled job(s)."
33
+ end
34
+ end
35
+
36
+ namespace :index do
37
+ # Rebuild the custom-field read-model index (Loam::CustomFieldIndex) for every
38
+ # entity with custom fields, in every tenant. Run once after enabling it so
39
+ # existing rows are projected; new/updated records index themselves on save.
40
+ #
41
+ # bin/rails loam:index:reindex
42
+ desc "Rebuild the custom-field read-model index for every model in every tenant"
43
+ task reindex: :environment do
44
+ Rails.application.eager_load!
45
+ models = Loam::TenantRecord.descendants.select do |model|
46
+ model.name.present? && model.respond_to?(:custom_field_definitions)
47
+ end
48
+
49
+ tenants = 0
50
+ Loam::Tenant.find_each do |tenant|
51
+ Loam.as_tenant(tenant) { models.each { |model| Loam::CustomFieldIndex.reindex(model) } }
52
+ tenants += 1
53
+ end
54
+ puts "loam:index:reindex — rebuilt #{models.size} model(s) across #{tenants} tenant(s)."
55
+ end
56
+
57
+ # Report custom-field index coverage (indexed vs expected) per model/field
58
+ # per tenant — the trust signal for whether the index is complete or drifting.
59
+ #
60
+ # bin/rails loam:index:coverage
61
+ desc "Report custom-field index coverage per model/field per tenant"
62
+ task coverage: :environment do
63
+ Rails.application.eager_load!
64
+ models = Loam::TenantRecord.descendants.select do |model|
65
+ model.name.present? && model.respond_to?(:custom_field_definitions)
66
+ end
67
+
68
+ Loam::Tenant.find_each do |tenant|
69
+ Loam.as_tenant(tenant) do
70
+ models.each do |model|
71
+ model.custom_field_definitions.find_each do |definition|
72
+ c = Loam::CustomFieldIndex.coverage(model, definition.name)
73
+ flag = c[:complete] ? "ok" : "GAP"
74
+ puts " [#{flag}] #{tenant.slug} #{model.name}.#{definition.name}: #{c[:indexed]}/#{c[:expected]} indexed"
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
81
+
82
+ namespace :search do
83
+ # Rebuild the active driver's search index for every searchable model in
84
+ # every tenant. Needed once after switching to a driver that keeps an index
85
+ # (Loam::Search::TokenDriver): existing rows have no tokens until reindexed,
86
+ # while new and updated records index themselves on save. A no-op under the
87
+ # default LikeDriver (its reindex does nothing), so it is always safe to run.
88
+ #
89
+ # bin/rails loam:search:reindex
90
+ desc "Rebuild the search index for every searchable model in every tenant"
91
+ task reindex: :environment do
92
+ Rails.application.eager_load!
93
+ models = Loam::TenantRecord.descendants.select do |model|
94
+ model.name.present? && model.respond_to?(:loam_searchable?) && model.loam_searchable?
95
+ end
96
+
97
+ tenants = 0
98
+ Loam::Tenant.find_each do |tenant|
99
+ Loam.as_tenant(tenant) { models.each { |model| Loam::Search.reindex(model) } }
100
+ tenants += 1
101
+ end
102
+ puts "loam:search:reindex — rebuilt #{models.size} model(s) across #{tenants} tenant(s) using #{Loam::Search.driver}."
103
+ end
104
+ end
105
+
106
+ namespace :encryption do
107
+ # Rotate a tenant's encrypted data under the current key: read each record's
108
+ # encrypted fields (old key) and re-seal them (new key), one record at a
109
+ # time. The version tag in the stored format means old and new ciphertext
110
+ # coexist, so this can run incrementally without downtime. Each record is an
111
+ # ordinary audited "[encrypted]" update.
112
+ #
113
+ # bin/rails loam:encryption:rotate[Customer,42]
114
+ desc "Re-encrypt a model's encrypted fields for one tenant (rotation step)"
115
+ task :rotate, %i[model tenant_id] => :environment do |_task, args|
116
+ model = fetch_encryptable_model(args[:model])
117
+ tenant = Loam::Tenant.find(args[:tenant_id])
118
+
119
+ count = 0
120
+ Loam.as_tenant(tenant) do
121
+ each_record(model) do |record|
122
+ record.loam_reencrypt!
123
+ count += 1
124
+ end
125
+ end
126
+ puts "loam:encryption:rotate — re-encrypted #{count} #{model.name} record(s) in tenant #{tenant.slug}."
127
+ end
128
+
129
+ # Decrypt and print one tenant's encrypted fields, e.g. for a GDPR data
130
+ # export or a migration. OPERATIONAL CAUTION: this prints plaintext PII to
131
+ # stdout — run it only where that output is safe (never into shared logs or
132
+ # a shell history that syncs), and only for a tenant you are authorized to
133
+ # export.
134
+ #
135
+ # bin/rails loam:encryption:decrypt_dump[Customer,42]
136
+ desc "Print decrypted encrypted-field values for one tenant (GDPR export; handle with care)"
137
+ task :decrypt_dump, %i[model tenant_id] => :environment do |_task, args|
138
+ require "json"
139
+ model = fetch_encryptable_model(args[:model])
140
+ tenant = Loam::Tenant.find(args[:tenant_id])
141
+ fields = model.loam_encrypted_attributes
142
+
143
+ Loam.as_tenant(tenant) do
144
+ each_record(model) do |record|
145
+ row = { id: record.id }.merge(fields.index_with { |field| record.public_send(field) })
146
+ puts row.to_json
147
+ end
148
+ end
149
+ end
150
+ end
151
+ end
152
+
153
+ # Resolve a model name to a class that actually uses Loam::Encryptable — a typo
154
+ # or a plain model should fail loudly, not silently dump/rotate nothing.
155
+ def fetch_encryptable_model(name)
156
+ # Force the app's classes (and Loam::TenantRecord, required lazily via the
157
+ # active_record on_load hook) to load before we constantize a model name.
158
+ Rails.application.eager_load!
159
+ model = name.to_s.constantize
160
+ unless model.respond_to?(:loam_encrypted_attributes) && model.loam_encrypted_attributes.any?
161
+ abort "#{name} does not `include Loam::Encryptable` with any `encrypts` fields."
162
+ end
163
+ model
164
+ end
165
+
166
+ # Iterate every record, soft-deleted ones included (a GDPR export must not miss
167
+ # data hidden in the recycle bin), staying inside the current tenant scope.
168
+ def each_record(model, &block)
169
+ scope = model.respond_to?(:with_deleted) ? model.with_deleted : model.all
170
+ scope.find_each(&block)
171
+ end
@@ -0,0 +1,32 @@
1
+ require "json"
2
+ require "fileutils"
3
+
4
+ namespace :loam do
5
+ # Score the app's current test suite as a golden-task eval result and record it
6
+ # under ai/benchmark_runs/ (L-303). Run this in a Loam app AFTER an agent (or
7
+ # you) has implemented a golden task:
8
+ #
9
+ # bin/rails "loam:eval[2]" # task 2, tests only
10
+ # bin/rails "loam:eval[2,tenancy_leak:unscoped]" # note an invariant breach
11
+ #
12
+ # Exits non-zero if the task did not pass, so it drops straight into CI.
13
+ desc "Score the current test suite as a golden-task eval result (loam:eval[task,violation,violation,...])"
14
+ task :eval, [ :task ] => :environment do |_task, args|
15
+ task_id = args[:task] || "unspecified"
16
+ violations = args.extras # any positional args after the task id are violations
17
+
18
+ output = `bin/rails test 2>&1`
19
+ summary = Loam::Eval.parse_summary(output)
20
+ card = Loam::Eval.scorecard(task: task_id, summary: summary, violations: violations)
21
+
22
+ dir = Rails.root.join("ai", "benchmark_runs")
23
+ FileUtils.mkdir_p(dir)
24
+ stamp = Time.now.utc.strftime("%Y%m%d%H%M%S")
25
+ file = dir.join("eval-#{task_id}-#{stamp}.json")
26
+ File.write(file, JSON.pretty_generate(card))
27
+
28
+ puts JSON.pretty_generate(card)
29
+ puts "Recorded #{file}"
30
+ abort("Eval FAILED for task #{task_id}") unless card[:passed]
31
+ end
32
+ end
@@ -0,0 +1,20 @@
1
+ require "logger"
2
+
3
+ namespace :loam do
4
+ namespace :mcp do
5
+ # Serve the Loam MCP server over stdio. Point an MCP client (Claude Code,
6
+ # Cursor, Codex) at `bin/rails loam:mcp:serve` with LOAM_MCP_TOKEN set to a
7
+ # Loam API token — the agent then acts as that token's user, in that tenant.
8
+ #
9
+ # stdout carries JSON-RPC only; every log is sent to stderr so it can't
10
+ # corrupt the stream.
11
+ desc "Serve the Loam MCP server over stdio (auth via LOAM_MCP_TOKEN)"
12
+ task serve: :environment do
13
+ Rails.logger = Logger.new($stderr)
14
+ ActiveRecord::Base.logger = Rails.logger
15
+ $stdout.sync = true
16
+
17
+ Loam::Mcp::Server.new.run
18
+ end
19
+ end
20
+ end