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,262 @@
1
+ module Loam
2
+ # The read-model index for custom fields (Loam::CustomFieldValue) — a typed-EAV
3
+ # projection that makes custom-field filter/sort/search index-backed.
4
+ #
5
+ # Loam::CustomFieldIndex.filter(DamageReport, "severity", "eq", "critical") # a relation
6
+ # Loam::CustomFieldIndex.order(DamageReport, "severity", :asc)
7
+ # Loam::CustomFieldIndex.reindex(DamageReport) # rebuild
8
+ #
9
+ # SAFETY: `field_key` must name a real Loam::FieldDefinition (no arbitrary
10
+ # keys); values are cast to the declared type; queries are tenant-scoped
11
+ # throughout (index rows carry tenant_id, the model relation is default-scoped),
12
+ # so a filter can't reach across tenants. Encrypted data lives in real columns,
13
+ # never in a custom field — nothing encrypted is projected here.
14
+ module CustomFieldIndex
15
+ OPS = %w[eq neq gt gte lt lte contains present blank].freeze
16
+
17
+ module_function
18
+
19
+ # --- maintenance (called from Loam::CustomFields) ---
20
+
21
+ # Re-project one record's custom fields (delete + insert). Soft-delete keeps
22
+ # the rows (the filter's base scope hides the record anyway — the same
23
+ # decision as the L-912 search token driver); a hard destroy removes them.
24
+ def project(record)
25
+ model = record.class
26
+ return unless model.respond_to?(:custom_field_definitions)
27
+
28
+ type = index_type(model)
29
+ Loam::CustomFieldValue.where(indexable_type: type, indexable_id: record.id).delete_all
30
+
31
+ rows = model.custom_field_definitions.filter_map { |definition| row_for(record, definition, type) }
32
+ Loam::CustomFieldValue.insert_all(rows) if rows.any?
33
+ end
34
+
35
+ def remove(record)
36
+ Loam::CustomFieldValue.where(indexable_type: index_type(record.class), indexable_id: record.id).delete_all
37
+ end
38
+
39
+ def reindex(model)
40
+ Loam::CustomFieldValue.where(indexable_type: index_type(model)).delete_all
41
+ model.find_each { |record| project(record) }
42
+ end
43
+
44
+ # --- query API ---
45
+
46
+ def filter(model, field_key, op, value = nil)
47
+ field_key = field_key.to_s
48
+ refuse_unknown_field!(model, field_key)
49
+ refuse_unreadable_field!(model, field_key)
50
+ raise Loam::Error, "unknown custom-field op #{op.inspect}" unless OPS.include?(op.to_s)
51
+
52
+ if covered?(model, field_key)
53
+ self.partial = false
54
+ index_filter(model, field_key, op, value)
55
+ else
56
+ # CORRECTNESS OVER SPEED: the index is known-incomplete for this field
57
+ # (coverage gap), so serve an authoritative JSON-scan result — never a
58
+ # silently-partial index set — AND enqueue a background reindex (deduped)
59
+ # to heal the gap so subsequent queries are fast again. `partial?` lets
60
+ # the caller surface an honest "results may be incomplete, reindexing…".
61
+ self.partial = true
62
+ schedule_reindex(model)
63
+ json_filter(model, field_key, op, value)
64
+ end
65
+ end
66
+
67
+ # The index-backed relation (used when coverage is complete).
68
+ def index_filter(model, field_key, op, value)
69
+ rows = base(model).where(field_key: field_key)
70
+
71
+ if op.to_s == "blank"
72
+ present_ids = rows.where.not(value_text: [ nil, "" ]).select(:indexable_id)
73
+ return model.where.not(id: present_ids)
74
+ end
75
+
76
+ model.where(id: predicate(rows, op.to_s, value).select(:indexable_id))
77
+ end
78
+
79
+ # Order a model's records by an indexed custom field (value_text — good for
80
+ # dictionary/string fields, the common case; numeric-aware ordering is a
81
+ # follow-up). Records with no value sort last (LEFT JOIN + NULLs).
82
+ def order(model, field_key, dir = :asc)
83
+ refuse_unknown_field!(model, field_key.to_s)
84
+ refuse_unreadable_field!(model, field_key.to_s)
85
+ # The LEFT JOIN keeps EVERY record (un-indexed ones sort as NULL), so an
86
+ # incomplete index mis-orders the gap but never drops a row. Flag it and
87
+ # heal in the background.
88
+ unless covered?(model, field_key.to_s)
89
+ self.partial = true
90
+ schedule_reindex(model)
91
+ end
92
+ table = Loam::CustomFieldValue.table_name
93
+ conn = model.connection
94
+ join = "LEFT JOIN #{table} ON #{table}.indexable_type = #{conn.quote(index_type(model))} " \
95
+ "AND #{table}.indexable_id = #{model.table_name}.id " \
96
+ "AND #{table}.field_key = #{conn.quote(field_key.to_s)} " \
97
+ "AND #{table}.tenant_id = #{Loam.tenant!.id}"
98
+ model.joins(join).order(Arel.sql("#{table}.value_text #{dir.to_s.casecmp('desc').zero? ? 'DESC' : 'ASC'}"))
99
+ end
100
+
101
+ # --- coverage + self-heal (L-919) ---
102
+
103
+ # How complete the index is for a field: how many live records HAVE a value
104
+ # (the authoritative JSON column) vs how many index rows exist. The trust
105
+ # signal — "9,980 of 10,000 indexed" tells an operator whether the index is
106
+ # complete or drifting (legacy data, a bulk write that bypassed the hook, a
107
+ # field def added before a backfill). Counting expected does a JSON pass, so
108
+ # it's a periodic READOUT, never a per-row query cost.
109
+ def coverage(model, field_key)
110
+ field_key = field_key.to_s
111
+ exp = expected(model, field_key)
112
+ got = indexed(model, field_key)
113
+ { field_key: field_key, expected: exp, indexed: got, complete: got >= exp,
114
+ ratio: exp.zero? ? 1.0 : (got.to_f / exp) }
115
+ end
116
+
117
+ def covered?(model, field_key)
118
+ coverage(model, field_key)[:complete]
119
+ end
120
+
121
+ def indexed(model, field_key)
122
+ base(model).where(field_key: field_key.to_s).where.not(value_text: [ nil, "" ]).count
123
+ end
124
+
125
+ def expected(model, field_key)
126
+ return 0 unless model.column_names.include?("custom_fields")
127
+
128
+ key = field_key.to_s
129
+ model.pluck(:custom_fields).count { |cf| cf.is_a?(Hash) && cf[key].to_s.strip != "" }
130
+ end
131
+
132
+ # Was the last filter/order served over an incomplete index? The admin surfaces
133
+ # this as an honest "results may be incomplete, reindexing…".
134
+ def partial?
135
+ Thread.current[:loam_index_partial] == true
136
+ end
137
+
138
+ def partial=(value)
139
+ Thread.current[:loam_index_partial] = value
140
+ end
141
+
142
+ # Enqueue a background reindex to heal a gap — DEDUPED so a hot gappy field
143
+ # doesn't enqueue one per request. In-process dedup here (single-process
144
+ # prototype); a DB/cache marker is the multi-process path.
145
+ def schedule_reindex(model)
146
+ key = [ Loam.tenant!.id, model.base_class.name ]
147
+ return if pending_reindex.include?(key)
148
+
149
+ pending_reindex << key
150
+ Loam::CustomFieldReindexJob.perform_later(Loam.tenant!.id, model.base_class.name)
151
+ end
152
+
153
+ def clear_pending(tenant_id, model_name)
154
+ pending_reindex.delete([ tenant_id, model_name ])
155
+ end
156
+
157
+ def reset_pending!
158
+ @pending_reindex = Set.new
159
+ end
160
+
161
+ def pending_reindex
162
+ @pending_reindex ||= Set.new
163
+ end
164
+
165
+ # Authoritative correct filter over the JSON column — DB-agnostic (reads each
166
+ # record's cast custom_field), used only as the fallback when the index is
167
+ # incomplete. Slow (a scan), which is exactly why the heal runs.
168
+ def json_filter(model, field_key, op, value)
169
+ ids = model.find_each.select { |record| json_match?(record, field_key, op, value) }.map(&:id)
170
+ model.where(id: ids)
171
+ end
172
+
173
+ def json_match?(record, field_key, op, value)
174
+ actual = (record.custom_field(field_key) rescue nil)
175
+ case op.to_s
176
+ when "eq" then actual.to_s == value.to_s
177
+ when "neq" then actual.to_s != value.to_s
178
+ when "present" then actual.to_s.strip != ""
179
+ when "blank" then actual.to_s.strip == ""
180
+ when "contains" then actual.to_s.include?(value.to_s)
181
+ when "gt" then actual.to_f > value.to_f
182
+ when "gte" then actual.to_f >= value.to_f
183
+ when "lt" then actual.to_f < value.to_f
184
+ when "lte" then actual.to_f <= value.to_f
185
+ end
186
+ end
187
+
188
+ # --- internals ---
189
+
190
+ def base(model)
191
+ Loam::CustomFieldValue.where(indexable_type: index_type(model)) # tenant-scoped by default_scope
192
+ end
193
+
194
+ def index_type(model_or_class)
195
+ klass = model_or_class.is_a?(Class) ? model_or_class : model_or_class.class
196
+ klass.base_class.name
197
+ end
198
+
199
+ def predicate(rows, op, value)
200
+ case op
201
+ when "eq" then rows.where(value_text: value.to_s)
202
+ when "neq" then rows.where.not(value_text: value.to_s)
203
+ when "contains" then rows.where("value_text LIKE ?", "%#{sanitize_like(value)}%")
204
+ when "present" then rows.where.not(value_text: [ nil, "" ])
205
+ when "gt" then numeric(rows, value, ">")
206
+ when "gte" then numeric(rows, value, ">=")
207
+ when "lt" then numeric(rows, value, "<")
208
+ when "lte" then numeric(rows, value, "<=")
209
+ end
210
+ end
211
+
212
+ # Numeric comparisons hit the indexed value_number column.
213
+ def numeric(rows, value, operator)
214
+ rows.where("value_number #{operator} ?", value.to_f)
215
+ end
216
+
217
+ def row_for(record, definition, type)
218
+ value = (record.custom_field(definition.name) rescue nil)
219
+ return nil if value.nil?
220
+
221
+ # ALL value columns are present (nil when unused) so insert_all sees a
222
+ # uniform key set across every row.
223
+ row = {
224
+ tenant_id: record.tenant_id, indexable_type: type, indexable_id: record.id,
225
+ field_key: definition.name, value_text: value.to_s,
226
+ value_number: nil, value_boolean: nil, value_datetime: nil
227
+ }
228
+ case definition.field_type
229
+ when "integer", "decimal" then row[:value_number] = value.to_f
230
+ when "boolean" then row[:value_boolean] = ActiveModel::Type::Boolean.new.cast(value)
231
+ when "date", "datetime" then row[:value_datetime] = (Time.zone.parse(value.to_s) rescue nil)
232
+ end
233
+ row
234
+ end
235
+
236
+ def refuse_unknown_field!(model, field_key)
237
+ known = model.respond_to?(:custom_field_definitions) && model.custom_field_definitions.exists?(name: field_key)
238
+ raise Loam::Error, "no custom field #{field_key.inspect} on #{model.name}" unless known
239
+ end
240
+
241
+ # THE oracle guard: filtering or sorting on a custom field the current role
242
+ # may not read would let a user infer restricted values by observing which
243
+ # records match. Enforced against the current actor's role; a system/background
244
+ # context (no actor — reindex, a business rule) is trusted and not gated.
245
+ def refuse_unreadable_field!(model, field_key)
246
+ actor = Loam::Current.actor
247
+ return if actor.nil?
248
+
249
+ definition = model.custom_field_definitions.find_by(name: field_key.to_s)
250
+ return if definition.nil? # unknown field is refuse_unknown_field!'s job
251
+
252
+ role = Loam::Membership.find_by(user_id: actor.id)&.role
253
+ return if definition.readable_by?(role)
254
+
255
+ raise Loam::FieldAccessError, "custom field #{field_key.inspect} is not readable by #{role.inspect}"
256
+ end
257
+
258
+ def sanitize_like(value)
259
+ value.to_s.gsub(/[\\%_]/) { |c| "\\#{c}" }
260
+ end
261
+ end
262
+ end
@@ -0,0 +1,114 @@
1
+ module Loam
2
+ # Runtime, migration-free fields — stored in the `custom_fields` json
3
+ # column every generated entity table carries. Included automatically by
4
+ # `rails g loam:entity`. Shape is declared at runtime via a
5
+ # Loam::FieldDefinition (typically through the admin "Field definitions"
6
+ # screen), not in code — that's what makes it migration-free.
7
+ #
8
+ # equipment.custom_field(:serial_number) # => "SN-123" (cast)
9
+ # equipment.set_custom_field(:serial_number, "SN-123")
10
+ #
11
+ # Reading/writing a name with no matching Loam::FieldDefinition for this
12
+ # tenant + entity raises Loam::UnknownCustomFieldError immediately — the
13
+ # same "fail loudly at the access site" philosophy as Loam.tenant!.
14
+ module CustomFields
15
+ extend ActiveSupport::Concern
16
+
17
+ included do
18
+ # Keep the read-model index (Loam::CustomFieldIndex) current so custom-field
19
+ # filter/sort is index-backed. Re-project only when the custom_fields column
20
+ # actually changed (a soft-delete touches only deleted_at, so the rows stay
21
+ # and the base scope hides the record — the L-912 posture). A no-op for a
22
+ # model with no custom_fields column. Guarded by respond_to? so Loam::
23
+ # CustomFields stays includable in a plain class for a DSL-only test.
24
+ if respond_to?(:after_save)
25
+ after_save :loam_reindex_custom_fields, if: :loam_custom_fields_dirty?
26
+ after_destroy :loam_deindex_custom_fields
27
+ end
28
+ end
29
+
30
+ class_methods do
31
+ def custom_field_definitions
32
+ Loam::FieldDefinition.where(entity_type: name)
33
+ end
34
+ end
35
+
36
+ def custom_field(name)
37
+ definition = custom_field_definition!(name)
38
+ cast_custom_field(definition.field_type, custom_fields[definition.name])
39
+ end
40
+
41
+ def set_custom_field(name, value)
42
+ definition = custom_field_definition!(name)
43
+ custom_fields[definition.name] = serialize_custom_field(definition.field_type, value)
44
+ value
45
+ end
46
+
47
+ # The display label for a custom field. For a dictionary-typed field this is
48
+ # the matching entry's label; for every other type it is just the value. A
49
+ # dictionary value is stored as its plain code, so read/write is unchanged —
50
+ # only the human-facing rendering differs.
51
+ def custom_field_label(name)
52
+ definition = custom_field_definition!(name)
53
+ value = custom_field(name)
54
+ return value unless definition.field_type == "dictionary"
55
+
56
+ Loam::Dictionaries.label_for(definition.dictionary_key, value)
57
+ end
58
+
59
+ private
60
+
61
+ def loam_reindex_custom_fields = Loam::CustomFieldIndex.project(self)
62
+ def loam_deindex_custom_fields = Loam::CustomFieldIndex.remove(self)
63
+
64
+ def loam_custom_fields_dirty?
65
+ return false unless self.class.respond_to?(:custom_field_definitions)
66
+
67
+ # On create every column counts as changed; on update, only re-project when
68
+ # the custom_fields json actually moved.
69
+ respond_to?(:saved_change_to_custom_fields?) ? saved_change_to_custom_fields? : true
70
+ end
71
+
72
+ def custom_field_definition!(name)
73
+ custom_field_definitions_by_name.fetch(name.to_s) do
74
+ raise UnknownCustomFieldError, "No Loam::FieldDefinition named #{name.inspect} for #{self.class.name} " \
75
+ "(tenant #{Loam.tenant&.id.inspect})"
76
+ end
77
+ end
78
+
79
+ # Memoized per-instance: several fields are typically read/written per
80
+ # request, and definitions vary per tenant so they can't be cached on the
81
+ # class itself.
82
+ def custom_field_definitions_by_name
83
+ @custom_field_definitions_by_name ||= self.class.custom_field_definitions.index_by(&:name)
84
+ end
85
+
86
+ def cast_custom_field(type, raw)
87
+ return nil if raw.nil?
88
+
89
+ case type.to_s
90
+ when "integer" then raw.to_i
91
+ when "decimal" then BigDecimal(raw.to_s)
92
+ when "boolean" then ActiveModel::Type::Boolean.new.cast(raw)
93
+ when "date" then Date.parse(raw.to_s)
94
+ when "datetime" then Time.zone.parse(raw.to_s)
95
+ else raw.to_s
96
+ end
97
+ end
98
+
99
+ def serialize_custom_field(type, value)
100
+ return nil if value.nil? || value == ""
101
+
102
+ case type.to_s
103
+ when "integer" then value.to_i
104
+ when "decimal" then value.to_s
105
+ when "boolean" then ActiveModel::Type::Boolean.new.cast(value)
106
+ when "date" then value.is_a?(Date) ? value.iso8601 : Date.parse(value.to_s).iso8601
107
+ when "datetime"
108
+ value.is_a?(Time) || value.is_a?(DateTime) ? value.utc.iso8601 : Time.zone.parse(value.to_s).utc.iso8601
109
+ else
110
+ value.to_s
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,17 @@
1
+ module Loam
2
+ # Resolves the admin dashboard for the current tenant/actor: the ordered,
3
+ # role-visible, computed widgets to show. Uses the tenant's configured
4
+ # Loam::DashboardWidget rows (a manager arranges them); falls back to every
5
+ # registered widget in registration order when the tenant has configured none,
6
+ # so the dashboard is useful out of the box.
7
+ module Dashboard
8
+ module_function
9
+
10
+ def for(actor:, role:)
11
+ configured = Loam::DashboardWidget.active.ordered.pluck(:widget_key)
12
+ keys = configured.presence || Loam::Widgets.keys
13
+
14
+ keys.filter_map { |key| Loam::Widgets.resolve(key, actor: actor, role: role) }
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,55 @@
1
+ module Loam
2
+ # Read API for per-tenant managed lookup lists (Loam::Dictionary /
3
+ # Loam::DictionaryEntry). Everything is tenant-scoped automatically and
4
+ # memoized per request in Loam::Current.dictionary_cache (keyed with the tenant
5
+ # id, so an as_tenant switch mid-request can't read another tenant's list) —
6
+ # the same simple prototype cache as Loam::Configs.
7
+ #
8
+ # Loam::Dictionaries.entries("damage_severity") # active, ordered
9
+ # Loam::Dictionaries.default("damage_severity") # the default entry
10
+ # Loam::Dictionaries.label_for("damage_severity", "critical") # => "Critical"
11
+ module Dictionaries
12
+ class << self
13
+ # The dictionary for the current tenant, or nil.
14
+ def get(key)
15
+ memo([:dict, key.to_s]) { Loam::Dictionary.find_by(key: key.to_s) }
16
+ end
17
+
18
+ # Active entries, ordered by position. Empty for an unknown key.
19
+ def entries(key)
20
+ dictionary = get(key)
21
+ return [] unless dictionary
22
+
23
+ memo([:entries, key.to_s]) { dictionary.entries.active.ordered.to_a }
24
+ end
25
+
26
+ # The default entry (the first flagged one), or nil.
27
+ def default(key)
28
+ entries(key).find(&:is_default?)
29
+ end
30
+
31
+ # The label for a stored value — or the raw value itself when the key or
32
+ # value is unknown, so an old/foreign value still renders as something.
33
+ def label_for(key, value)
34
+ return value if value.blank?
35
+
36
+ entry = entries(key).find { |e| e.value == value.to_s }
37
+ entry ? entry.label : value
38
+ end
39
+
40
+ def clear_cache
41
+ Loam::Current.dictionary_cache = {}
42
+ end
43
+
44
+ private
45
+
46
+ def memo(subkey)
47
+ store = (Loam::Current.dictionary_cache ||= {})
48
+ cache_key = subkey + [ Loam.tenant&.id ]
49
+ return store[cache_key] if store.key?(cache_key)
50
+
51
+ store[cache_key] = yield
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,174 @@
1
+ module Loam
2
+ # Durable (persistent) event subscribers — the twin of Loam::Events.subscribe,
3
+ # and the formal contract L-706 pins down.
4
+ #
5
+ # THE CONTRACT
6
+ # * Ephemeral — Loam::Events.subscribe(&block): runs INLINE in the publisher's
7
+ # thread, synchronously, best-effort. An exception in the block PROPAGATES
8
+ # into whatever published the event. No persistence, no retry. Right for
9
+ # cheap in-process fan-out (the webhook dispatcher enqueues its own jobs
10
+ # this way).
11
+ # * Persistent — Loam::DurableEvents.register(...): publishing commits a
12
+ # Loam::EventDelivery row in the event's tenant, then hands the handler to a
13
+ # background job. The handler's exception is CONTAINED in the job; delivery
14
+ # is retried with backoff and, past MAX_ATTEMPTS, parked as `dead` for an
15
+ # operator (Admin::EventDeliveriesController).
16
+ #
17
+ # GUARANTEE: at-least-once, UNORDERED. Handlers MUST be idempotent — a retry or
18
+ # the sweep may deliver the same event twice. Durability is of DELIVERY, not
19
+ # CAPTURE: an event whose process dies between the after_commit and the publish
20
+ # leaves no row and is lost, exactly as today. This feature makes what WAS
21
+ # published arrive; it does not resurrect what was never published.
22
+ #
23
+ # SECURITY: a handler is resolved from THIS in-memory registry by key, populated
24
+ # at boot from trusted code — never constantized from the stored row. If the key
25
+ # is unknown at delivery time (handler removed since enqueue) the row is parked
26
+ # `dead`; an arbitrary class is never executed off a DB value. (Same posture as
27
+ # the scheduler's job_class allowlist.)
28
+ module DurableEvents
29
+ MAX_ATTEMPTS = 5
30
+ # Backoff (seconds) indexed by attempt number; the last value is the cap.
31
+ BACKOFF = [ 0, 60, 300, 1800, 7200 ].freeze
32
+ SWEEP_KEY = "loam_event_redelivery_sweep".freeze
33
+
34
+ class << self
35
+ # --- declarative registry (mirrors Loam::Scheduler.register) ---
36
+
37
+ # key: a stable identifier stored on every delivery row it produces.
38
+ # to: an event name ("billing.invoice.paid") or a domain prefix
39
+ # ("billing.") — same matching rule as Events/webhooks.
40
+ # call: a class (or anything) responding to .call(event_name, payload),
41
+ # or its name as a String. Resolved from this registry, never the row.
42
+ def register(key:, to:, call:)
43
+ registry[key.to_s] = { key: key.to_s, pattern: to.to_s, handler: call }
44
+ key.to_s
45
+ end
46
+
47
+ def registered = registry.values
48
+
49
+ def reset_registry!
50
+ @registry = {}
51
+ end
52
+
53
+ def subscribers_for(event_name)
54
+ registered.select { |entry| Loam::Events.pattern_matches?(entry[:pattern], event_name) }
55
+ end
56
+
57
+ def handler_for(key)
58
+ entry = registry[key.to_s]
59
+ entry && resolve(entry[:handler])
60
+ end
61
+
62
+ # --- wiring (mirrors Loam::Webhooks.subscribe!) ---
63
+
64
+ # Wired once from Loam::Engine. Idempotent: subscribing twice would persist
65
+ # every event twice.
66
+ def subscribe!
67
+ @subscription ||= Loam::Events.subscribe_all { |event_name, payload| capture(event_name, payload) }
68
+ end
69
+
70
+ # Persist a delivery row per matching durable subscriber, in the event's
71
+ # tenant, then nudge a job per row. The ROW is the durable record; the job
72
+ # is only the accelerator (the sweep redelivers rows whose job was lost).
73
+ def capture(event_name, payload)
74
+ tenant = Loam::Tenant.find_by(id: payload[:tenant_id])
75
+ return if tenant.nil? # nil-tenant events are not durably delivered (as Webhooks.dispatch)
76
+
77
+ matches = subscribers_for(event_name)
78
+ return if matches.empty?
79
+
80
+ # Only JSON-safe primitives cross into the row/job — payloads are ids and
81
+ # scalars by convention, never records (same rule as the webhook path).
82
+ deliverable = payload.transform_keys(&:to_s)
83
+
84
+ Loam.as_tenant(tenant) do
85
+ matches.each do |sub|
86
+ delivery = Loam::EventDelivery.create!(
87
+ subscriber_key: sub[:key], event_name: event_name.to_s,
88
+ payload: deliverable, status: "pending", attempts: 0
89
+ )
90
+ Loam::EventDeliveryJob.perform_later(tenant.id, delivery.id)
91
+ end
92
+ end
93
+ end
94
+
95
+ # --- delivery (called by Loam::EventDeliveryJob) ---
96
+
97
+ # Run one delivery and advance the row's state. Manual, ROW-STATE retries —
98
+ # deliberately NOT ActiveJob retry_on, which keeps retry state in the queue
99
+ # (the very thing this feature stops trusting). Safe to call twice on one
100
+ # row (at-least-once): a delivered/dead/not-yet-due row is a no-op.
101
+ def deliver(delivery, now: Time.current)
102
+ return unless delivery.status == "pending"
103
+ return if delivery.next_attempt_at && delivery.next_attempt_at > now # duplicate nudge, backoff not elapsed
104
+
105
+ Loam::Telemetry.span("durable_event_delivery",
106
+ subscriber_key: delivery.subscriber_key, event_name: delivery.event_name) do
107
+ run_delivery(delivery, now)
108
+ end
109
+ end
110
+
111
+ def run_delivery(delivery, now)
112
+ handler = handler_for(delivery.subscriber_key)
113
+ if handler.nil?
114
+ delivery.update!(status: "dead",
115
+ last_error: "no registered subscriber #{delivery.subscriber_key.inspect}")
116
+ return
117
+ end
118
+
119
+ begin
120
+ handler.call(delivery.event_name, delivery.payload_hash)
121
+ delivery.update!(status: "delivered", delivered_at: now, last_error: nil)
122
+ rescue StandardError => error
123
+ attempts = delivery.attempts + 1
124
+ if attempts >= MAX_ATTEMPTS
125
+ delivery.update!(status: "dead", attempts: attempts, last_error: format_error(error))
126
+ else
127
+ delivery.update!(attempts: attempts, next_attempt_at: now + backoff_for(attempts),
128
+ last_error: format_error(error))
129
+ end
130
+ end
131
+ end
132
+
133
+ # THE durability guarantee: re-enqueue due-but-undelivered rows whose job
134
+ # was lost (worker crash, dropped message, async adapter racing the txn).
135
+ # Tenant-scoped — called from the per-tenant sweep the engine registers, so
136
+ # no cross-tenant scan is needed.
137
+ def redeliver_stuck(now: Time.current, limit: 500)
138
+ count = 0
139
+ Loam::EventDelivery.due(now).limit(limit).find_each do |delivery|
140
+ Loam::EventDeliveryJob.perform_later(delivery.tenant_id, delivery.id)
141
+ count += 1
142
+ end
143
+ count
144
+ end
145
+
146
+ private
147
+
148
+ def registry
149
+ @registry ||= {}
150
+ end
151
+
152
+ # A callable passed directly (class with .call, Proc) is used as-is; a
153
+ # String/Symbol is constantized HERE, from the trusted registry value.
154
+ def resolve(handler)
155
+ if handler.is_a?(String) || handler.is_a?(Symbol)
156
+ klass = handler.to_s.safe_constantize
157
+ return klass if klass.respond_to?(:call)
158
+
159
+ nil
160
+ elsif handler.respond_to?(:call)
161
+ handler
162
+ end
163
+ end
164
+
165
+ def backoff_for(attempts)
166
+ BACKOFF[attempts] || BACKOFF.last
167
+ end
168
+
169
+ def format_error(error)
170
+ "#{error.class}: #{error.message}"
171
+ end
172
+ end
173
+ end
174
+ end