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,102 @@
1
+ module Loam
2
+ module BusinessRules
3
+ # The SAFE condition evaluator — the critical security boundary of the rules
4
+ # engine. A rule is DATA an admin edits, so this must NEVER run arbitrary
5
+ # code: it supports only `and`/`or`/`not` and leaf comparisons
6
+ # `{ field, op, value }`, and a `field` may name ONLY a real, non-plumbing,
7
+ # non-encrypted column of the record OR a declared custom field. Anything
8
+ # else is refused — no `send` of arbitrary methods, no SQL, no eval.
9
+ module Condition
10
+ OPS = %w[eq neq gt gte lt lte in contains present blank].freeze
11
+ REFUSED = :__loam_field_refused__
12
+
13
+ def self.matches?(node, record)
14
+ node = node.is_a?(Hash) ? node : {}
15
+ return true if node.empty? # no condition = always fires
16
+
17
+ if node.key?("and")
18
+ Array(node["and"]).all? { |child| matches?(child, record) }
19
+ elsif node.key?("or")
20
+ Array(node["or"]).any? { |child| matches?(child, record) }
21
+ elsif node.key?("not")
22
+ !matches?(node["not"], record)
23
+ else
24
+ leaf(node, record)
25
+ end
26
+ end
27
+
28
+ def self.leaf(node, record)
29
+ op = node["op"].to_s
30
+ return false unless OPS.include?(op)
31
+
32
+ actual = read_field(node["field"].to_s, record)
33
+ return false if actual == REFUSED # an un-whitelisted field never matches
34
+
35
+ compare(op, actual, node["value"])
36
+ end
37
+
38
+ # A whitelisted read, or REFUSED. `tenant_id` is refused as an isolation
39
+ # footgun; ENCRYPTED columns are refused because a contains/eq rule over
40
+ # their decrypted value would be an oracle leaking the secret through the
41
+ # rule's behaviour. Everything unknown is refused, never sent.
42
+ def self.read_field(field, record)
43
+ klass = record.class
44
+ return REFUSED if refused_column?(klass, field)
45
+
46
+ if klass.column_names.include?(field)
47
+ record.public_send(field)
48
+ elsif custom_field?(record, field)
49
+ begin
50
+ record.custom_field(field)
51
+ rescue Loam::UnknownCustomFieldError
52
+ REFUSED
53
+ end
54
+ else
55
+ REFUSED
56
+ end
57
+ end
58
+
59
+ def self.refused_column?(klass, field)
60
+ return true if field == "tenant_id"
61
+
62
+ klass.respond_to?(:loam_encrypted_attributes) &&
63
+ klass.loam_encrypted_attributes.map(&:to_s).include?(field)
64
+ end
65
+
66
+ def self.custom_field?(record, field)
67
+ record.class.respond_to?(:custom_field_definitions) &&
68
+ record.class.custom_field_definitions.exists?(name: field)
69
+ end
70
+
71
+ def self.compare(op, actual, expected)
72
+ case op
73
+ when "eq" then values_equal?(actual, expected)
74
+ when "neq" then !values_equal?(actual, expected)
75
+ when "in" then Array(expected).map(&:to_s).include?(actual.to_s)
76
+ when "contains" then actual.to_s.include?(expected.to_s)
77
+ when "present" then actual.present?
78
+ when "blank" then actual.blank?
79
+ else
80
+ cmp = numeric_compare(actual, expected)
81
+ return false if cmp.nil?
82
+
83
+ { "gt" => cmp.positive?, "gte" => cmp >= 0, "lt" => cmp.negative?, "lte" => cmp <= 0 }[op]
84
+ end
85
+ end
86
+
87
+ def self.values_equal?(actual, expected)
88
+ actual == expected || actual.to_s == expected.to_s || numeric_compare(actual, expected)&.zero? || false
89
+ end
90
+
91
+ # Compare as numbers when both coerce; else fall back to the natural
92
+ # comparison; incomparable types yield nil (so the op is simply false).
93
+ def self.numeric_compare(actual, expected)
94
+ Float(actual) <=> Float(expected)
95
+ rescue ArgumentError, TypeError
96
+ actual <=> expected
97
+ rescue StandardError
98
+ nil
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,123 @@
1
+ require "loam/business_rules/condition"
2
+ require "loam/business_rules/actions"
3
+
4
+ module Loam
5
+ # The engine that evaluates Loam::BusinessRule rows: WHEN a trigger fires and
6
+ # the condition holds, THEN run the actions — in the event's tenant context,
7
+ # in priority order, each rule isolated so one failure never breaks dispatch.
8
+ #
9
+ # It listens to every Loam event (like Loam::Webhooks), so a tenant can add a
10
+ # rule from the admin without a reboot. Evaluation runs INLINE in whatever
11
+ # published the event — cheap for the prototype; a job-based dispatcher is the
12
+ # scaling path.
13
+ module BusinessRules
14
+ MAX_DEPTH = 3 # bounds a rule chain: an emit_event action can trigger more
15
+ # rules, but only so deep, so a self-triggering rule terminates.
16
+
17
+ class << self
18
+ # Wired once from Loam::Engine. Idempotent — subscribing twice would run
19
+ # every rule twice (see Loam::Webhooks.subscribe!).
20
+ def subscribe!
21
+ @subscription ||= Loam::Events.subscribe_all { |event_name, payload| on_event(event_name, payload) }
22
+ end
23
+
24
+ def on_event(event_name, payload)
25
+ tenant = Loam::Tenant.find_by(id: payload[:tenant_id])
26
+ return if tenant.nil?
27
+
28
+ Loam.as_tenant(tenant) do
29
+ within_depth_guard do
30
+ Loam::BusinessRule.active.by_priority.each do |rule|
31
+ next unless rule.matches_trigger?(event_name)
32
+
33
+ run_rule(rule, subject_for(rule, payload), event_name)
34
+ end
35
+ end
36
+ end
37
+ end
38
+
39
+ # Manual evaluation for a record — for programmatic/test use. Runs the
40
+ # active rules for the record's entity type (optionally filtered to a
41
+ # trigger) in priority order.
42
+ def evaluate(record, trigger: nil)
43
+ Loam::BusinessRule.active.for_entity(record.class.base_class.name).by_priority.each do |rule|
44
+ next if trigger && !rule.matches_trigger?(trigger)
45
+
46
+ run_rule(rule, record, trigger)
47
+ end
48
+ end
49
+
50
+ # Do the active rules with a block_transition action, whose condition
51
+ # holds, veto this transition? Consulted by a workflow BEFORE it moves (see
52
+ # the demo's DamageReport). Evaluate-only — no actions run, nothing logged.
53
+ def veto?(record, trigger)
54
+ Loam::BusinessRule.active.for_entity(record.class.base_class.name).by_priority.any? do |rule|
55
+ rule.matches_trigger?(trigger) &&
56
+ rule.action_list.any? { |action| action["type"].to_s == "block_transition" } &&
57
+ Condition.matches?(rule.condition_tree, record)
58
+ end
59
+ end
60
+
61
+ private
62
+
63
+ def run_rule(rule, record, event_name)
64
+ if record.nil? && rule.entity_type.present?
65
+ return log(rule, nil, event_name, matched: false, error: "subject not found")
66
+ end
67
+
68
+ matched = Condition.matches?(rule.condition_tree, record)
69
+ taken = []
70
+ # block_transition is a veto signal consumed elsewhere, not an action to
71
+ # perform here, so it is skipped in the THEN path.
72
+ rule.action_list.each { |action| taken << Actions.run(action, record) } if matched && record
73
+
74
+ log(rule, record, event_name, matched: matched, actions_taken: taken.compact) if matched || taken.any?
75
+ rescue StandardError => error
76
+ log(rule, record, event_name, matched: false, error: error.message)
77
+ end
78
+
79
+ def log(rule, record, event_name, matched:, actions_taken: [], error: nil)
80
+ # Lean: log the runs that ACTED, errored, or found no subject — not the
81
+ # deterministic "condition didn't match" ones (readable from the rule).
82
+ return if !matched && error.nil?
83
+
84
+ Loam::BusinessRuleRun.create!(
85
+ business_rule: rule,
86
+ subject_type: record&.class&.base_class&.name,
87
+ subject_id: record&.id,
88
+ event_name: event_name,
89
+ matched: matched,
90
+ actions_taken: actions_taken,
91
+ error: error
92
+ )
93
+ end
94
+
95
+ # A rule watches a specific entity; load THAT record from the rule's
96
+ # entity_type + the event's id (find_by, not find — a record deleted
97
+ # between publish and dispatch is a missing subject, not a crash).
98
+ def subject_for(rule, payload)
99
+ return nil if rule.entity_type.blank? || payload[:id].blank?
100
+
101
+ klass = rule.entity_type.safe_constantize
102
+ # A rule may ONLY target a tenant-scoped model. This is the security
103
+ # boundary: a global model like `User` has no tenant default scope, so a
104
+ # find_by(id:) would reach across tenants — refusing anything that is not
105
+ # a Loam::TenantRecord closes that, and a TenantRecord.find_by can only
106
+ # ever return a record in the CURRENT tenant even if an id is injected.
107
+ return nil unless klass.is_a?(Class) && klass < Loam::TenantRecord
108
+
109
+ klass.find_by(id: payload[:id])
110
+ end
111
+
112
+ def within_depth_guard
113
+ depth = Thread.current[:loam_rule_depth] ||= 0
114
+ return if depth >= MAX_DEPTH
115
+
116
+ Thread.current[:loam_rule_depth] = depth + 1
117
+ yield
118
+ ensure
119
+ Thread.current[:loam_rule_depth] = depth if depth
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,22 @@
1
+ module Loam
2
+ # Discussion on a record:
3
+ #
4
+ # equipment.comment!("Hydraulics serviced") # as Loam.actor
5
+ # equipment.loam_comments.oldest_first
6
+ #
7
+ # Included by every generated entity. Comments are tenant-scoped like their
8
+ # subject, so a discussion can never be read from another tenant.
9
+ module Commentable
10
+ extend ActiveSupport::Concern
11
+
12
+ included do
13
+ has_many :loam_comments, as: :commentable, class_name: "Loam::Comment", dependent: :destroy
14
+ end
15
+
16
+ # Author defaults to whoever is acting — the same person the audit trail
17
+ # and the event payload would name.
18
+ def comment!(body, author: Loam::Current.actor)
19
+ loam_comments.create!(body: body, author: author)
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,115 @@
1
+ module Loam
2
+ # The one way to read and write per-tenant settings.
3
+ #
4
+ # Loam::Configs.get("rental.late_fee_per_day") # resolved for the current tenant
5
+ # Loam::Configs.set("rental.late_fee_per_day", 45) # this tenant's override
6
+ # Loam::Configs.set("rental.currency", "PLN", scope: :global)
7
+ # Loam::Configs.reset("rental.late_fee_per_day") # drop the override → fall back
8
+ #
9
+ # Resolution order, most specific first:
10
+ # 1. the current tenant's OVERRIDE row (tenant_id = current tenant)
11
+ # 2. the GLOBAL row (tenant_id NULL)
12
+ # 3. the declared default in Loam.config_defaults[key]
13
+ # 4. the `default:` argument (nil)
14
+ #
15
+ # Reads are memoized per request in Loam::Current.config_cache, keyed by
16
+ # [key, tenant_id], and the whole cache is dropped on any write. That is the
17
+ # deliberately-simple prototype cache; a Rails.cache-backed layer shared across
18
+ # requests is the scaling path, and it would slot in behind this same API.
19
+ module Configs
20
+ class << self
21
+ def get(key, default: nil)
22
+ key = key.to_s
23
+ store = cache
24
+ cache_key = [key, current_tenant_id]
25
+
26
+ return store[cache_key] if store.key?(cache_key)
27
+
28
+ # Only a value that actually RESOLVED (a row or a declared default) is
29
+ # cached — never the caller's `default:` fallback. The cache key omits
30
+ # `default:`, so caching it would make a later get with a different
31
+ # default wrongly return the first one when nothing is configured.
32
+ found, value = resolve(key)
33
+ return default unless found
34
+
35
+ store[cache_key] = value
36
+ end
37
+
38
+ # Write a setting. scope: :tenant writes the current tenant's override and
39
+ # requires a tenant in context (a missing one raises, like every Loam
40
+ # write); scope: :global writes the app-wide row (tenant_id NULL). Upsert.
41
+ def set(key, value, scope: :tenant)
42
+ key = key.to_s
43
+ record = row_for(key, scope).first_or_initialize
44
+ record.value_json = value
45
+ record.save!
46
+ clear_cache
47
+ value
48
+ end
49
+
50
+ # Drop the current tenant's override for a key so it falls back to the
51
+ # global row / declared default. Requires a tenant in context. No-op if
52
+ # there was no override. Returns true.
53
+ def reset(key)
54
+ row_for(key.to_s, :tenant).delete_all
55
+ clear_cache
56
+ true
57
+ end
58
+
59
+ # Whether the current tenant has an override for this key (vs. inheriting
60
+ # the global/declared value). Drives the admin "overridden?" column.
61
+ def overridden?(key)
62
+ row_for(key.to_s, :tenant).exists?
63
+ end
64
+
65
+ # Every key an admin might configure for the current tenant: declared
66
+ # defaults, plus any global or current-tenant rows already written. Sorted
67
+ # and de-duplicated, for the settings screen.
68
+ def defined_keys
69
+ rows = Loam::Config.where(tenant_id: [nil, Loam.tenant&.id]).distinct.pluck(:key)
70
+ (Loam.config_defaults.keys + rows).map(&:to_s).uniq.sort
71
+ end
72
+
73
+ private
74
+
75
+ # Returns [found?, value]. `found?` is false only when nothing resolved —
76
+ # the caller then applies its own `default:` without caching it.
77
+ def resolve(key)
78
+ if (tenant_id = current_tenant_id)
79
+ override = Loam::Config.find_by(key: key, tenant_id: tenant_id)
80
+ return [true, override.value_json] if override
81
+ end
82
+
83
+ global = Loam::Config.find_by(key: key, tenant_id: nil)
84
+ return [true, global.value_json] if global
85
+
86
+ return [true, Loam.config_defaults[key]] if Loam.config_defaults.key?(key)
87
+
88
+ [false, nil]
89
+ end
90
+
91
+ # The row(s) a write/read targets. :global is the tenant_id NULL row;
92
+ # :tenant is the current tenant's — which requires a tenant in context,
93
+ # so Loam.tenant! raises MissingTenantError when there is none.
94
+ def row_for(key, scope)
95
+ case scope
96
+ when :global then Loam::Config.where(key: key, tenant_id: nil)
97
+ when :tenant then Loam::Config.where(key: key, tenant_id: Loam.tenant!.id)
98
+ else raise ArgumentError, "unknown config scope #{scope.inspect} (use :tenant or :global)"
99
+ end
100
+ end
101
+
102
+ def current_tenant_id
103
+ Loam.tenant&.id
104
+ end
105
+
106
+ def cache
107
+ Loam::Current.config_cache ||= {}
108
+ end
109
+
110
+ def clear_cache
111
+ Loam::Current.config_cache = {}
112
+ end
113
+ end
114
+ end
115
+ end
data/lib/loam/cron.rb ADDED
@@ -0,0 +1,98 @@
1
+ require "set"
2
+
3
+ module Loam
4
+ # A small, dependency-free "next run" calculator for a standard 5-field cron
5
+ # expression ("min hour dom month dow") plus an "interval:N" (every N seconds)
6
+ # form — same stdlib-only spirit as Loam::Totp / Loam::Base32. Timezone-aware;
7
+ # always returns UTC. Supports `*`, lists (`1,15`), ranges (`1-5`), and steps
8
+ # (`*/15`, `0-30/10`), and the classic cron day-of-month / day-of-week OR quirk
9
+ # (when BOTH are restricted, a match on either fires).
10
+ module Cron
11
+ RANGES = { min: 0..59, hour: 0..23, dom: 1..31, mon: 1..12, dow: 0..6 }.freeze
12
+ MAX_HORIZON = 4 # years to search before giving up (covers Feb-29 schedules)
13
+
14
+ module_function
15
+
16
+ # The next run strictly after `from`, evaluated in `zone`, returned in UTC.
17
+ def next_after(expr, from:, zone: "UTC")
18
+ expr = expr.to_s.strip
19
+
20
+ if expr.start_with?("interval:")
21
+ seconds = expr.split(":", 2).last.to_i
22
+ raise ArgumentError, "interval must be a positive number of seconds: #{expr.inspect}" unless seconds.positive?
23
+
24
+ return (from + seconds).utc
25
+ end
26
+
27
+ next_cron(expr, from, zone)
28
+ end
29
+
30
+ def next_cron(expr, from, zone)
31
+ fields = expr.split(/\s+/)
32
+ raise ArgumentError, "cron must have 5 fields (min hour dom month dow): #{expr.inspect}" unless fields.size == 5
33
+
34
+ min = parse_field(fields[0], RANGES[:min])
35
+ hour = parse_field(fields[1], RANGES[:hour])
36
+ dom = parse_field(fields[2], RANGES[:dom])
37
+ mon = parse_field(fields[3], RANGES[:mon])
38
+ dow = parse_field(fields[4], RANGES[:dow], dow: true)
39
+ dom_restricted = fields[2] != "*"
40
+ dow_restricted = fields[4] != "*"
41
+
42
+ time = from.in_time_zone(zone).change(sec: 0) + 60
43
+ limit = time + MAX_HORIZON.years
44
+
45
+ while time < limit
46
+ unless mon.include?(time.month) && day_matches?(time, dom, dow, dom_restricted, dow_restricted)
47
+ time = (time + 1.day).change(hour: 0, min: 0) # skip the whole non-matching day
48
+ next
49
+ end
50
+ return time.utc if hour.include?(time.hour) && min.include?(time.min)
51
+
52
+ time += 60
53
+ end
54
+
55
+ raise ArgumentError, "no cron match within #{MAX_HORIZON} years for #{expr.inspect}"
56
+ end
57
+
58
+ def day_matches?(time, dom, dow, dom_restricted, dow_restricted)
59
+ if dom_restricted && dow_restricted
60
+ dom.include?(time.day) || dow.include?(time.wday) # OR quirk
61
+ elsif dom_restricted
62
+ dom.include?(time.day)
63
+ elsif dow_restricted
64
+ dow.include?(time.wday)
65
+ else
66
+ true
67
+ end
68
+ end
69
+
70
+ # Parse one field into the set of matching integers.
71
+ def parse_field(field, range, dow: false)
72
+ values = Set.new
73
+
74
+ field.to_s.split(",").each do |part|
75
+ base, step = part.split("/", 2)
76
+ step = step ? Integer(step) : 1
77
+ raise ArgumentError, "bad step in #{part.inspect}" unless step.positive?
78
+
79
+ if base == "*"
80
+ lo, hi = range.min, range.max
81
+ elsif base.include?("-")
82
+ lo, hi = base.split("-", 2).map { |n| Integer(n) }
83
+ else
84
+ lo = hi = Integer(base)
85
+ end
86
+
87
+ (lo..hi).step(step) do |value|
88
+ value = 0 if dow && value == 7 # both 0 and 7 mean Sunday
89
+ raise ArgumentError, "value #{value} out of range for #{field.inspect}" unless range.include?(value)
90
+
91
+ values << value
92
+ end
93
+ end
94
+
95
+ values
96
+ end
97
+ end
98
+ end
data/lib/loam/csv.rb ADDED
@@ -0,0 +1,18 @@
1
+ module Loam
2
+ # CSV formula-injection defense. A cell whose value starts with =, +, -, @, or
3
+ # a tab/CR is treated as a FORMULA by Excel/Google Sheets when the file is
4
+ # opened — so `=cmd|...` or `=HYPERLINK(...)` in imported/exported data can run.
5
+ # Prefixing such a value with a single quote neutralizes it while leaving the
6
+ # visible text intact. Applied on every CSV cell Loam writes (export + the
7
+ # import error file).
8
+ module Csv
9
+ DANGEROUS = /\A[=+\-@\t\r]/
10
+
11
+ module_function
12
+
13
+ def safe(value)
14
+ string = value.to_s
15
+ string.match?(DANGEROUS) ? "'#{string}" : value
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,21 @@
1
+ require "active_support/current_attributes"
2
+
3
+ module Loam
4
+ # Per-request (and per-job) execution context. Every tenant-scoped query,
5
+ # audit record, and published event reads from here.
6
+ class Current < ActiveSupport::CurrentAttributes
7
+ # config_cache memoizes resolved Loam::Configs values for the duration of
8
+ # one request/job, keyed by [key, tenant_id]. It resets with everything else
9
+ # here, so a value can never be read stale across requests.
10
+ #
11
+ # mutation_mode is the approval-gate seam (Loam::PendingActions): nil/:direct
12
+ # means writes commit normally; :confirm means a caller (an MCP tool acting
13
+ # for an AI agent) stages writes for human approval instead. It lives here so
14
+ # it is per-request and resets automatically.
15
+ # dictionary_cache memoizes resolved Loam::Dictionaries lookups for one
16
+ # request/job, keyed by [kind, key, tenant_id] — same posture as config_cache.
17
+ # locale is the current content-translation language (Loam::Translatable) —
18
+ # request state like tenant/actor, set in a before_action and reset here.
19
+ attribute :tenant, :actor, :config_cache, :mutation_mode, :dictionary_cache, :locale
20
+ end
21
+ end