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,145 @@
1
+ module Loam
2
+ # Field-level encryption at rest, keyed per tenant. Declare it on a model:
3
+ #
4
+ # class Patient < Loam::TenantRecord
5
+ # include Loam::Encryptable
6
+ # encrypts :ssn # encrypted at rest, not searchable
7
+ # encrypts :email, searchable: true # + a blind index for exact-match lookup
8
+ # end
9
+ #
10
+ # Patient.create!(ssn: "078-05-1120") # the ssn COLUMN now holds "v1:...."
11
+ # patient.ssn # => "078-05-1120" (decrypted on read)
12
+ # Patient.find_by_email("a@b.com") # exact match via the blind index
13
+ #
14
+ # The value is sealed with the CURRENT tenant's key (Loam.tenant!), never the
15
+ # record's stored tenant_id — so a read in the wrong tenant's context fails the
16
+ # GCM auth tag instead of quietly decrypting another tenant's data. Reading or
17
+ # writing an encrypted field with no tenant in context raises
18
+ # MissingTenantError: you cannot encrypt without knowing whose key.
19
+ #
20
+ # Encryption happens eagerly on assignment, so re-submitting a form with the
21
+ # same value re-seals it under a fresh IV and records a "[encrypted]" audit
22
+ # update with no real change — accepted prototype noise.
23
+ module Encryptable
24
+ extend ActiveSupport::Concern
25
+
26
+ included do
27
+ class_attribute :loam_encrypted_attributes, default: [].freeze, instance_writer: false
28
+ class_attribute :loam_searchable_encrypted_attributes, default: [].freeze, instance_writer: false
29
+ end
30
+
31
+ class_methods do
32
+ # `scope:` chooses whose key seals the field. `:tenant` (default) keys off
33
+ # Loam.tenant! — right for entity data. A Proc `->(record) { "user/#{...}" }`
34
+ # keys off something else, for genuinely non-tenant data (an MFA secret
35
+ # belongs to the person and must decrypt in any tenant, and at login before
36
+ # a tenant is chosen). Non-tenant scopes cannot be `searchable`.
37
+ def encrypts(name, searchable: false, scope: :tenant)
38
+ name = name.to_s
39
+
40
+ if searchable && scope != :tenant
41
+ raise Loam::Error, "#{self.name}: `#{name}` cannot be both `searchable` and non-tenant-scoped."
42
+ end
43
+
44
+ # Encrypted ciphertext is meaningless to a LIKE scan, so the two are a
45
+ # contradiction. Caught whichever declaration comes second (Searchable
46
+ # runs the mirror check), so order in the model does not matter.
47
+ if respond_to?(:loam_searchable_columns) && loam_searchable_columns.include?(name)
48
+ raise Loam::Error,
49
+ "#{self.name}: `#{name}` is declared both `searchable_by` (LIKE) and `encrypts` — " \
50
+ "ciphertext cannot be LIKE-searched. Drop it from `searchable_by` and use " \
51
+ "`encrypts :#{name}, searchable: true` for exact-match lookup instead."
52
+ end
53
+
54
+ # A translation row would store the field's PLAINTEXT — recreating the
55
+ # leak encryption closes. Caught whichever declaration comes second
56
+ # (Translatable runs the mirror check), so model order does not matter.
57
+ if respond_to?(:loam_translatable_attributes) && loam_translatable_attributes.include?(name)
58
+ raise Loam::Error,
59
+ "#{self.name}: `#{name}` is declared both `translates` and `encrypts` — a translation " \
60
+ "would store plaintext. Encrypted data is not translatable."
61
+ end
62
+
63
+ self.loam_encrypted_attributes = (loam_encrypted_attributes + [name]).freeze
64
+ self.loam_searchable_encrypted_attributes = (loam_searchable_encrypted_attributes + [name]).freeze if searchable
65
+
66
+ include loam_encryption_reader_writer(name, searchable, scope)
67
+ define_loam_blind_index_finder(name) if searchable
68
+ end
69
+
70
+ # Reader/writer live in their own module (the Loam::Workflow precedent) so
71
+ # an app can override and still call `super`, and so they sit ABOVE Active
72
+ # Record's generated attribute methods in the ancestor chain and win.
73
+ def loam_encryption_reader_writer(name, searchable, scope)
74
+ hash_column = "#{name}_hash"
75
+
76
+ Module.new do
77
+ define_method(name) do
78
+ resolved = loam_encryption_scope(scope)
79
+ aad = Loam::Encryption.aad(resolved, self.class.table_name, name)
80
+ Loam::Encryption.decrypt_scoped(read_attribute(name), resolved, aad: aad)
81
+ end
82
+
83
+ define_method("#{name}=") do |value|
84
+ resolved = loam_encryption_scope(scope)
85
+ # Bind this ciphertext to its (scope, table, column) so it can't be
86
+ # transplanted to another column/table/tenant (v2 AAD).
87
+ aad = Loam::Encryption.aad(resolved, self.class.table_name, name)
88
+ write_attribute(name, Loam::Encryption.encrypt_scoped(value, resolved, aad: aad))
89
+ # The blind index tracks the ciphertext column: rewrite it in the
90
+ # same breath, so an exact-match lookup can never go stale.
91
+ write_attribute(hash_column, Loam::Encryption.blind_index_scoped(value, resolved)) if searchable
92
+ end
93
+ end
94
+ end
95
+
96
+ # `find_by_<name>` / `where_<name>` match the per-tenant blind index. They
97
+ # deliberately shadow Active Record's dynamic `find_by_<attr>`: the obvious
98
+ # call must hash-and-compare, never match a plaintext query against the
99
+ # ciphertext column (which would silently find nothing).
100
+ def define_loam_blind_index_finder(name)
101
+ hash_column = "#{name}_hash"
102
+
103
+ define_singleton_method("where_#{name}") do |value|
104
+ where(hash_column => Loam::Encryption.blind_index(value, Loam.tenant!.id))
105
+ end
106
+
107
+ define_singleton_method("find_by_#{name}") do |value|
108
+ public_send("where_#{name}", value).first
109
+ end
110
+ end
111
+ end
112
+
113
+ private
114
+
115
+ # Resolve a declared `scope:` to the namespaced owner string the key is
116
+ # derived from. `:tenant` keys off the current tenant (raises with none, the
117
+ # same safety property as an entity write); a Proc computes it from the
118
+ # record. A blank or `.../`-terminated result (e.g. a nil user_id) raises
119
+ # rather than deriving a degenerate shared key.
120
+ def loam_encryption_scope(scope)
121
+ resolved = scope == :tenant ? "tenant/#{Loam.tenant!.id}" : scope.call(self).to_s
122
+
123
+ if resolved.strip.empty? || resolved.end_with?("/")
124
+ raise Loam::Encryption::Error, "#{self.class}: cannot derive an encryption key from a blank scope (#{resolved.inspect})"
125
+ end
126
+ resolved
127
+ end
128
+
129
+ public
130
+
131
+ # Re-seal every encrypted field under the current key, with fresh IVs — the
132
+ # per-record step of a key rotation (read old, write new). With HKDF-from-
133
+ # master, a real rotation means a new master or a bumped Cipher::VERSION; the
134
+ # version tag lets old and new ciphertext coexist while this runs across a
135
+ # tenant's records, so rotation is a lazy re-encrypt, not a stop-the-world
136
+ # migration. Writes an ordinary audited "[encrypted]" update — see
137
+ # lib/tasks/loam.rake (loam:encryption:rotate).
138
+ def loam_reencrypt!
139
+ self.class.loam_encrypted_attributes.each do |name|
140
+ public_send("#{name}=", public_send(name))
141
+ end
142
+ save!
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,75 @@
1
+ module Loam
2
+ module Encryption
3
+ # AES-256-GCM sealing. GCM is *authenticated* encryption: the 16-byte auth
4
+ # tag turns tampering — or decrypting with the wrong key — into a loud
5
+ # failure on open, never silent garbage.
6
+ #
7
+ # Stored format, one string column:
8
+ #
9
+ # "v1:" + base64( iv[12] ++ auth_tag[16] ++ ciphertext ) (no AAD)
10
+ # "v2:" + base64( iv[12] ++ auth_tag[16] ++ ciphertext ) sealed WITH AAD
11
+ #
12
+ # The version tag lets the scheme evolve without a stop-the-world migration:
13
+ # v2 binds Additional Authenticated Data (the field's tenant+table+column) into
14
+ # the auth tag, so a ciphertext moved to a DIFFERENT column/table/tenant fails
15
+ # the tag on read — it can't be transplanted. Old "v1:" rows (no AAD) keep
16
+ # decrypting, so upgrading is a lazy re-encrypt (loam:encryption:rotate writes
17
+ # v2), never a data migration. The AAD is authenticated but NOT secret — it
18
+ # never conceals anything, it only pins WHERE the ciphertext is allowed to live.
19
+ module Cipher
20
+ VERSION = "v1".freeze # legacy, no AAD — still readable
21
+ V2 = "v2".freeze # current writes — AAD-bound
22
+ IV_BYTES = 12 # GCM's standard nonce size
23
+ TAG_BYTES = 16 # full-length GCM tag; a shorter tag weakens authentication
24
+
25
+ # Encrypt with a fresh random IV. Reusing an IV under one key is
26
+ # catastrophic for GCM, so the IV is never derived or fixed — always
27
+ # OpenSSL's CSPRNG, once per value. With an `aad:` the ciphertext is bound
28
+ # to that context (v2); without one it stays v1 (a bare tenant-scoped blob).
29
+ def self.seal(plaintext, key, aad: nil)
30
+ cipher = OpenSSL::Cipher.new("aes-256-gcm").encrypt
31
+ cipher.key = key
32
+ iv = cipher.random_iv
33
+ version = aad ? V2 : VERSION
34
+ cipher.auth_data = aad if aad # folded into the tag, not encrypted
35
+ ciphertext = cipher.update(plaintext) + cipher.final
36
+ tag = cipher.auth_tag(TAG_BYTES)
37
+ "#{version}:" + [iv + tag + ciphertext].pack("m0")
38
+ end
39
+
40
+ # Decrypt, or raise Loam::Encryption::DecryptionError. The wrong tenant's
41
+ # key, a tampered blob, a truncated tag, a v2 blob read with the WRONG (or
42
+ # missing) AAD, or plain garbage all fail the same loud way — never a
43
+ # partial or silently-wrong plaintext. A v1 blob carries no AAD, so the
44
+ # passed `aad:` is ignored for it (backward compatible).
45
+ def self.open(payload, key, aad: nil)
46
+ version, blob = payload.to_s.split(":", 2)
47
+ raise DecryptionError, "unrecognized ciphertext format" unless [ VERSION, V2 ].include?(version) && blob
48
+
49
+ raw = blob.unpack1("m0")
50
+ # Enforce the full IV+tag length BEFORE slicing: OpenSSL will verify a
51
+ # truncated tag against a truncated blob, so a short payload must be
52
+ # rejected here, not handed to the cipher.
53
+ raise DecryptionError, "ciphertext too short" if raw.nil? || raw.bytesize < IV_BYTES + TAG_BYTES
54
+
55
+ iv = raw.byteslice(0, IV_BYTES)
56
+ tag = raw.byteslice(IV_BYTES, TAG_BYTES)
57
+ ciphertext = raw.byteslice(IV_BYTES + TAG_BYTES..) || ""
58
+
59
+ cipher = OpenSSL::Cipher.new("aes-256-gcm").decrypt
60
+ cipher.key = key
61
+ cipher.iv = iv
62
+ cipher.auth_tag = tag
63
+ cipher.auth_data = aad if version == V2 && aad # v2 rows require the matching AAD
64
+ plaintext = cipher.update(ciphertext) + cipher.final
65
+ # Decryption yields ASCII-8BIT bytes; our columns hold UTF-8 text.
66
+ plaintext.force_encoding(Encoding::UTF_8)
67
+ rescue OpenSSL::Cipher::CipherError, ArgumentError, TypeError
68
+ # $! is preserved as the DecryptionError's `cause`. The message stays
69
+ # deliberately vague — it must not distinguish "wrong key" from
70
+ # "corrupt data" to a caller.
71
+ raise DecryptionError, "could not decrypt (wrong key or corrupt data)"
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,43 @@
1
+ module Loam
2
+ module Encryption
3
+ # The seam a real KMS plugs into. A provider turns (scope, purpose) into a
4
+ # 32-byte data key; swap the default for a Vault/AWS-KMS-backed provider via
5
+ # `Loam::Encryption.key_provider = MyKmsProvider.new` and NO call site
6
+ # changes — Cipher and Encryptable only ever ask for a key.
7
+ #
8
+ # `scope` is a namespaced owner string: "tenant/5" for an entity field,
9
+ # "user/12" for genuinely user-owned data (an MFA secret) that must decrypt
10
+ # regardless of which tenant the user is currently acting in.
11
+ class KeyProvider
12
+ def data_key(scope:, purpose:)
13
+ raise NotImplementedError, "#{self.class} must implement #data_key(scope:, purpose:)"
14
+ end
15
+ end
16
+
17
+ # Default provider: derive a per-scope, per-purpose key from one master key
18
+ # with HKDF-SHA256. Deterministic, so no key needs to be stored, and one
19
+ # scope's key can NEVER equal another's because the scope is bound into the
20
+ # HKDF `info`. Purpose separation means the encryption key and the
21
+ # blind-index (HMAC) key derived for one scope are independent.
22
+ class HkdfKeyProvider < KeyProvider
23
+ # A fixed, non-secret salt. HKDF's strength comes from the master key's
24
+ # entropy; the salt only has to be stable so derivation is reproducible.
25
+ SALT = "loam.encryption.hkdf.v1".freeze
26
+ KEY_BYTES = 32 # AES-256 and HMAC-SHA256 both take a 32-byte key
27
+
28
+ def data_key(scope:, purpose:)
29
+ raise ArgumentError, "scope is required to derive a key" if scope.nil? || scope.to_s.empty?
30
+
31
+ OpenSSL::KDF.hkdf(
32
+ Loam::Encryption.master_key,
33
+ salt: SALT,
34
+ # info binds the key to owner AND purpose. "tenant/5" here reproduces
35
+ # the pre-scope format exactly, so existing ciphertext still decrypts.
36
+ info: "loam/#{purpose}/#{scope}",
37
+ length: KEY_BYTES,
38
+ hash: "SHA256"
39
+ )
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,125 @@
1
+ require "openssl"
2
+ require "loam/encryption/key_provider"
3
+ require "loam/encryption/cipher"
4
+
5
+ module Loam
6
+ # Field-level encryption at rest, keyed per tenant.
7
+ #
8
+ # The facade the rest of Loam calls: `encrypt`/`decrypt` seal and open a value
9
+ # with the tenant's derived AES-256-GCM key, and `blind_index` computes the
10
+ # per-tenant HMAC used to find an encrypted field by exact value. Key
11
+ # derivation is delegated to a pluggable `key_provider` (HKDF by default, a
12
+ # KMS in production), so this module holds the scheme, not the key material.
13
+ module Encryption
14
+ class Error < Loam::Error; end
15
+
16
+ # Raised when a crypto operation is attempted with no master key configured.
17
+ class MissingMasterKeyError < Error
18
+ def initialize(msg = "Loam::Encryption has no master key. Set LOAM_MASTER_KEY (or " \
19
+ "`Loam::Encryption.master_key = ...`) to a high-entropy secret, e.g. " \
20
+ "`SecureRandom.hex(32)`. NEVER commit it; use ENV or Rails credentials.")
21
+ super
22
+ end
23
+ end
24
+
25
+ # Raised by decrypt on the wrong key, tampering, truncation, or garbage —
26
+ # one loud, undifferentiated failure.
27
+ class DecryptionError < Error; end
28
+
29
+ # HKDF extracts entropy from whatever it is given, but a short master key is
30
+ # a short master key — refuse anything below 256 bits of material.
31
+ MASTER_KEY_MIN_BYTES = 32
32
+
33
+ class << self
34
+ attr_writer :key_provider
35
+
36
+ def key_provider
37
+ @key_provider ||= HkdfKeyProvider.new
38
+ end
39
+
40
+ def master_key=(value)
41
+ @master_key = value
42
+ end
43
+
44
+ def master_key
45
+ key = @master_key || ENV["LOAM_MASTER_KEY"]
46
+ raise MissingMasterKeyError if key.nil? || key.empty?
47
+ if key.bytesize < MASTER_KEY_MIN_BYTES
48
+ raise MissingMasterKeyError,
49
+ "LOAM_MASTER_KEY is too short (#{key.bytesize} bytes); use at least " \
50
+ "#{MASTER_KEY_MIN_BYTES}, e.g. `SecureRandom.hex(32)`."
51
+ end
52
+ key
53
+ end
54
+
55
+ # Tenant-scoped operations — the default for entity fields via
56
+ # Loam::Encryptable. nil stays nil (an unset field is not "the empty
57
+ # string encrypted"); any other value is stringified and sealed.
58
+ def encrypt(plaintext, tenant_id)
59
+ encrypt_scoped(plaintext, tenant_scope(tenant_id))
60
+ end
61
+
62
+ def decrypt(payload, tenant_id)
63
+ decrypt_scoped(payload, tenant_scope(tenant_id))
64
+ end
65
+
66
+ # A deterministic, per-tenant keyed hash for exact-match lookup of an
67
+ # encrypted field. It leaks equality WITHIN a tenant (same value → same
68
+ # hash) — the accepted trade-off for searchability — but the per-tenant
69
+ # HMAC key means the same value hashes differently across tenants, so
70
+ # equality never leaks between them. Only searchable fields get one.
71
+ def blind_index(value, tenant_id)
72
+ blind_index_scoped(value, tenant_scope(tenant_id))
73
+ end
74
+
75
+ # Explicit-scope variants, for data owned by something OTHER than a tenant
76
+ # — an MFA secret, say, keyed "user/42" so it decrypts in whatever tenant
77
+ # the user is currently in, or at login when no tenant is chosen yet.
78
+ def encrypt_scoped(plaintext, scope, aad: nil)
79
+ return nil if plaintext.nil?
80
+ Cipher.seal(plaintext.to_s, data_key(scope, :encryption), aad: aad)
81
+ end
82
+
83
+ def decrypt_scoped(payload, scope, aad: nil)
84
+ return nil if payload.nil?
85
+ Cipher.open(payload, data_key(scope, :encryption), aad: aad)
86
+ end
87
+
88
+ # The Additional Authenticated Data that BINDS a ciphertext to where it
89
+ # lives — the key scope (tenant/owner) + table + column. Reconstructed
90
+ # identically on read and write, so a blob moved to a different column,
91
+ # table, or tenant fails the auth tag. NOT the record id (see
92
+ # Loam::Encryptable): the id is unknown at INSERT time, and binding it would
93
+ # force an ugly post-insert double-write; record-swap within one
94
+ # tenant+table+column stays a documented residual.
95
+ def aad(scope, table, column)
96
+ "loam-aad:v2:#{scope}:#{table}:#{column}"
97
+ end
98
+
99
+ def blind_index_scoped(value, scope)
100
+ return nil if value.nil?
101
+ OpenSSL::HMAC.hexdigest("SHA256", data_key(scope, :blind_index), value.to_s)
102
+ end
103
+
104
+ private
105
+
106
+ # "tenant/5" reproduces the pre-scope HKDF info exactly (see HkdfKeyProvider).
107
+ def tenant_scope(tenant_id)
108
+ "tenant/#{tenant_id}"
109
+ end
110
+
111
+ # Central guard for every crypto path (tenant and explicit scope alike): a
112
+ # nil tenant makes the scope "tenant/", a nil owner id makes "user/" — a
113
+ # degenerate scope that would otherwise derive a real, SHARED key. Refuse
114
+ # it here so `encrypt(x, nil)` fails like the Encryptable-layer guard does,
115
+ # rather than silently keying unrelated records together.
116
+ def data_key(scope, purpose)
117
+ if scope.nil? || scope.to_s.strip.empty? || scope.to_s.end_with?("/")
118
+ raise ArgumentError, "refusing to derive an encryption key from a degenerate scope #{scope.inspect}"
119
+ end
120
+
121
+ key_provider.data_key(scope: scope, purpose: purpose)
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,57 @@
1
+ module Loam
2
+ class Engine < ::Rails::Engine
3
+ # Not isolated on purpose: Loam models live under the Loam:: namespace but
4
+ # share the host app's routes/helpers, keeping the prototype surface small.
5
+
6
+ # Webhook dispatch listens to every Loam event. Wired after initialization
7
+ # so the models it queries are loadable, and guarded against subscribing
8
+ # twice (see Loam::Webhooks.subscribe!).
9
+ config.after_initialize do
10
+ Loam::Webhooks.subscribe!
11
+ Loam::DurableEvents.subscribe! # persist + retry durable subscribers (L-706)
12
+ Loam::BusinessRules.subscribe!
13
+ Loam::Widgets.register_builtins! # the default dashboard widgets
14
+ Loam::Overrides.check! # warn about any stale disable/replace overrides
15
+
16
+ # The durability sweep runs per-tenant on a schedule (materialized into
17
+ # each tenant by Loam::Scheduler.sync_tenant). interval:300 = every 5 min.
18
+ Loam::Scheduler.register(
19
+ key: Loam::DurableEvents::SWEEP_KEY, name: "Event redelivery sweep",
20
+ job_class: "Loam::EventRedeliverySweepJob", schedule: "interval:300", scope: "tenant"
21
+ )
22
+ end
23
+
24
+ # Loam's Active Record layer — TenantRecord, Auditable, Eventful and the
25
+ # rest — is defined inside ActiveSupport.on_load(:active_record) in
26
+ # lib/loam.rb, so those constants exist only once something has referenced
27
+ # ActiveRecord::Base.
28
+ #
29
+ # Zeitwerk eager-loads app/models alphabetically. A host model that inherits
30
+ # from Loam::TenantRecord and sorts ahead of application_record.rb reaches
31
+ # the constant before anything has touched Base, and eager load dies with
32
+ # "uninitialized constant Loam::TenantRecord". Whether an app hits it
33
+ # depends on its model names, so it shows up on a rename rather than on the
34
+ # change that caused it.
35
+ #
36
+ # Referencing Base here fires the hook while load order is still the
37
+ # engine's to decide instead of each host app's. :eager_load! is late enough
38
+ # that every active_record.* initializer has applied its configuration, and
39
+ # it runs in every environment (in development it is a no-op that still
40
+ # executes), so lazily-loaded apps are settled by the same line.
41
+ initializer "loam.active_record_layer", before: :eager_load! do
42
+ ActiveRecord::Base
43
+ end
44
+
45
+ # Loam's own UI strings ship under lib/ (the gemspec packages lib/**/*), so
46
+ # register them on the app's I18n load path explicitly rather than relying on
47
+ # the default config/locales path (which the gem does not ship). An app
48
+ # overrides any key with its own config/locales/loam.<locale>.yml.
49
+ initializer "loam.i18n" do |app|
50
+ app.config.i18n.load_path += Dir[File.expand_path("locales/*.yml", __dir__)]
51
+ end
52
+
53
+ # lib/tasks/loam.rake (bin/rails loam:sync) is picked up by Rails::Engine's
54
+ # own lib/tasks loading. Loading it again from a `rake_tasks` block here
55
+ # would define the task twice and run its body twice.
56
+ end
57
+ end
@@ -0,0 +1,105 @@
1
+ module Loam
2
+ # Response enrichers: one module attaches a computed block onto ANOTHER
3
+ # module's entity in admin/API responses, with no foreign-key coupling.
4
+ #
5
+ # # billing knows about equipment; equipment knows nothing about billing:
6
+ # Loam::Enrichers.register("Equipment", key: "outstanding_balance") do |equipment|
7
+ # Invoice.where(equipment_id: equipment.id).sum(:balance)
8
+ # end
9
+ #
10
+ # Loam::Enrichers.enrich(equipment) # => { "outstanding_balance" => 1200 }
11
+ # Loam::Enrichers.enrich_many(equipments) # => { id => { key => value }, ... }
12
+ #
13
+ # Distinct from custom fields: a custom field is STORED on the record; an
14
+ # enricher is COMPUTED at read time by different (possibly cross-module) code.
15
+ #
16
+ # BATCH to avoid N+1: pass `batch:` (an array -> { record.id => value }) and
17
+ # `enrich_many` resolves N records in one query instead of N. `enrich` reuses
18
+ # the batch path for a single record, so batched enrichers are cheap either way.
19
+ #
20
+ # Enrichers run in the CURRENT tenant context (the record was loaded there), so
21
+ # a resolver querying tenant-scoped models can only ever see this tenant's data.
22
+ # A resolver that raises is ISOLATED — its key is omitted, the rest still
23
+ # resolve, and the response is never broken. (No timeout in the prototype — a
24
+ # pathologically slow enricher is a future concern.)
25
+ module Enrichers
26
+ Enricher = Struct.new(:entity_type, :key, :priority, :resolver, :batch_resolver, keyword_init: true)
27
+
28
+ class << self
29
+ # Register an enricher for records of `entity_type` (the model's base-class
30
+ # name, e.g. "Equipment"). Provide a per-record block OR a `batch:` proc.
31
+ def register(entity_type, key:, priority: 0, batch: nil, &resolver)
32
+ raise ArgumentError, "register needs a block or a batch: resolver" unless resolver || batch
33
+
34
+ registry[entity_type.to_s] << Enricher.new(
35
+ entity_type: entity_type.to_s, key: key.to_s, priority: priority,
36
+ resolver: resolver, batch_resolver: batch
37
+ )
38
+ end
39
+
40
+ def enrich(record)
41
+ enrich_many([record]).fetch(record.id, {})
42
+ end
43
+
44
+ # { record.id => { key => value } } for a homogeneous array of records.
45
+ def enrich_many(records)
46
+ records = Array(records)
47
+ return {} if records.empty?
48
+
49
+ types = records.map { |record| record.class.base_class }.uniq
50
+ raise ArgumentError, "enrich_many expects one entity type, got #{types.map(&:name).join(', ')}" if types.size > 1
51
+
52
+ by_id = records.each_with_object({}) { |record, hash| hash[record.id] = {} }
53
+
54
+ for_type(types.first).each do |enricher|
55
+ # Per-enricher isolation: a raise omits THIS key for all records; the
56
+ # others still resolve.
57
+ begin
58
+ if enricher.batch_resolver
59
+ values = enricher.batch_resolver.call(records)
60
+ records.each { |record| by_id[record.id][enricher.key] = values[record.id] }
61
+ else
62
+ records.each { |record| by_id[record.id][enricher.key] = enricher.resolver.call(record) }
63
+ end
64
+ rescue StandardError => error
65
+ warn_failure(enricher, error)
66
+ end
67
+ end
68
+
69
+ by_id
70
+ end
71
+
72
+ # --- test support: the registry is process-global, so a test snapshots it
73
+ # in setup and restores in teardown (keeping the app's boot-registered
74
+ # enrichers intact, which `clear!` would not).
75
+ def snapshot
76
+ registry.transform_values(&:dup)
77
+ end
78
+
79
+ def restore(snapshot)
80
+ @registry = Hash.new { |hash, key| hash[key] = [] }
81
+ snapshot.each { |type, enrichers| @registry[type] = enrichers.dup }
82
+ end
83
+
84
+ private
85
+
86
+ def registry
87
+ @registry ||= Hash.new { |hash, key| hash[key] = [] }
88
+ end
89
+
90
+ # The enrichers for a class. Distinct keys compose; if two register the
91
+ # SAME key, higher priority wins, ties go to the later registration.
92
+ def for_type(klass)
93
+ registry[klass.base_class.name]
94
+ .group_by(&:key)
95
+ .map { |_key, group| group.each_with_index.max_by { |enricher, index| [enricher.priority, index] }.first }
96
+ end
97
+
98
+ def warn_failure(enricher, error)
99
+ Rails.logger&.warn(
100
+ "[Loam::Enrichers] #{enricher.entity_type}/#{enricher.key} raised #{error.class}: #{error.message} — key omitted"
101
+ )
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,49 @@
1
+ module Loam
2
+ class Error < StandardError; end
3
+
4
+ # Raised whenever a tenant-scoped model is touched with no tenant in
5
+ # Loam::Current. This is a structural guardrail, not a convention: forgetting
6
+ # the tenant context fails loudly (in tests, before it leaks) instead of
7
+ # silently returning or writing cross-tenant data.
8
+ class MissingTenantError < Error
9
+ def initialize(msg = "No tenant set in Loam::Current — wrap this call in Loam.as_tenant(tenant) { ... }")
10
+ super
11
+ end
12
+ end
13
+
14
+ # Raised by admin controllers / callers when a policy check fails, and by a
15
+ # Loam::Workflow transition the current actor's role may not perform.
16
+ class NotAuthorizedError < Error; end
17
+
18
+ # Raised when a Loam::Workflow transition is attempted from a state it does
19
+ # not move from ("approve a report that was never submitted"). Like every
20
+ # Loam guardrail it fails at the call site rather than writing a state the
21
+ # machine says is impossible.
22
+ class InvalidTransitionError < Error; end
23
+
24
+ # Raised when an event name does not follow the `domain.thing.happened` convention.
25
+ class InvalidEventNameError < Error; end
26
+
27
+ # Raised by Loam::CustomFields#custom_field/#set_custom_field when the name
28
+ # has no matching Loam::FieldDefinition for this tenant + entity. Fails
29
+ # loudly at the access site rather than silently reading/writing nil.
30
+ class UnknownCustomFieldError < Error; end
31
+
32
+ # Raised by require_feature! when a capability is turned OFF for the current
33
+ # tenant. Distinct from NotAuthorizedError on purpose: a disabled feature is
34
+ # "not here" (the capability does not exist for this tenant), not "you may
35
+ # not" — so admin controllers render it as 404, not 403.
36
+ class FeatureDisabledError < Error; end
37
+
38
+ # Raised when a filter/sort is attempted on a custom field the current role may
39
+ # not read (Loam::CustomFieldIndex) — otherwise a filter would be an inference
40
+ # oracle on a restricted field. A NotAuthorizedError so admin controllers render
41
+ # it as 403, like any other field-access denial.
42
+ class FieldAccessError < NotAuthorizedError; end
43
+
44
+ # Raised when a Loam::BusinessRule with a `block_transition` action vetoes a
45
+ # workflow transition. Distinct from NotAuthorizedError (a role gate) and
46
+ # InvalidTransitionError (an illegal move): the move is legal and permitted,
47
+ # but a rule says "not under these conditions".
48
+ class TransitionVetoedError < Error; end
49
+ end
data/lib/loam/eval.rb ADDED
@@ -0,0 +1,47 @@
1
+ module Loam
2
+ # Scripted evaluation scoring for the golden tasks (L-303). An agent (or a
3
+ # human) implements a task against a fresh Loam app; this scores the RESULT
4
+ # consistently — the test outcome plus the structural invariants — into a
5
+ # machine-readable scorecard, so runs are comparable over time
6
+ # (ai/benchmark_runs/). Driving the agent is out of scope; consistent scoring
7
+ # is the point, and it encodes the golden-tasks bar: green suite AND no
8
+ # invariant violated.
9
+ module Eval
10
+ # The minitest summary line, whatever the counts: "N runs, M assertions,
11
+ # F failures, E errors, S skips".
12
+ SUMMARY = /(\d+)\s+runs?,\s+(\d+)\s+assertions?,\s+(\d+)\s+failures?,\s+(\d+)\s+errors?/
13
+
14
+ module_function
15
+
16
+ def parse_summary(output)
17
+ match = output.to_s.match(SUMMARY)
18
+ return nil unless match
19
+
20
+ { runs: match[1].to_i, assertions: match[2].to_i, failures: match[3].to_i, errors: match[4].to_i }
21
+ end
22
+
23
+ # A task PASSES only when the suite is green AND no invariant was violated —
24
+ # the bar the golden-tasks doc sets. `violations` is a list of invariant
25
+ # breaches a reviewer (or a lint) found (tenancy leak, unauthorized write, …);
26
+ # `interventions` counts human corrections needed. Both feed the comparison,
27
+ # neither is inferred here.
28
+ def scorecard(task:, summary:, violations: [], interventions: 0, notes: nil)
29
+ counts = summary || { runs: 0, assertions: 0, failures: 0, errors: 0 }
30
+ green = counts[:failures].to_i.zero? && counts[:errors].to_i.zero? && counts[:runs].to_i.positive?
31
+ passed = green && Array(violations).empty?
32
+
33
+ {
34
+ task: task.to_s,
35
+ passed: passed,
36
+ tests_green: green,
37
+ runs: counts[:runs].to_i,
38
+ assertions: counts[:assertions].to_i,
39
+ failures: counts[:failures].to_i,
40
+ errors: counts[:errors].to_i,
41
+ violations: Array(violations),
42
+ interventions: interventions.to_i,
43
+ notes: notes
44
+ }
45
+ end
46
+ end
47
+ end